| Dimension | Shared | Only in A | Only in B | Overlap |
|---|---|---|---|---|
| Sections | 0 | 19 | 39 | 0% |
| Commands | 0 | 25 | 8 | 0% |
| Section tags | 8 | 5 | 5 | 44% |
What each file covers
Sections
0 shared · 19 only in A · 39 only in B- − CLAUDE.md
- − Conventions
- − Project Shape
- − State Rules
- − Package Boundaries
- − Sharing Rules
- − Commands
- − Database and Migration Rules
- − Coding Rules
- − API Compatibility
- − Backend UUID Rules
- − Web/Desktop Features
- − Desktop Rules
- − Mobile Rules
- − UI Rules
- − Testing
- − Verification
- − Commits and Releases
- − Domain Reminders
- + Mobile App Rules (apps/mobile/)
- + What mobile may import from `packages/`
- + Pre-flight — before you write any code
- + 1. Read the real web/desktop implementation
- + 2. Show the user the interaction plan + parity points (≤30s to read)
- + 3. Wait for an explicit "do it / go / start" before writing code
- + Behavioral parity with web/desktop
- + ⚠️ Incident (2026-05-09): inbox dedup missing — counts disagreed
- + Tech-stack baseline
- + UI components & theming
- + Hard rule — existing pattern first, defaults first, native waterfall
- + Component placement
- + Theming model — CSS variables + class-based dark mode
- + What this replaces (and what stays)
- + Build & release
- + Realtime / WebSocket strategy
- + Three-layer stack
- + Mount strategy: list-level global, per-record per-screen
- + Patch over invalidate (cellular-data rule)
- + Mobile-owned updaters (don't import `packages/core/issues/ws-updaters.ts`)
- + Event-always-wins (optimistic conflict policy)
- + Reconnect handling
- + Cross-cutting cache patches across features
- + Adding new event coverage — recipe
- + Data layer helpers (use these — don't recreate them)
- + Three rails that every feature must follow
- + API client: `fetchValidated` + `fetchValidatedWith`
- + Query / mutation factory pattern
- + WS layer: `ws.on<E>()` + `useWSSubscriptions`
- + Synchronous setQueryData before `await cancelQueries`
- + Checklist for a new feature
- + Lessons learned (encode into reflexes)
- + 1. Install/upgrade any dependency: check `dist-tags` first
- + 2. New source subdirectory: verify git tracking
- + 3. ApiClient capability list (4 must-haves)
- + 4. Every read query must pass `signal` to fetch; api.ts always has a hard timeout
- + 5. Modal container selection: match container to content, don't copy the first sheet
- + 6. Destructive swipe: reveal only, no auto-fire — always pair with haptic
- + 7. Tier C domain components: opportunistic upgrade only — no silent rewrites
Commands
0 shared · 25 only in A · 8 only in B- − make dev
- − make start
- − make stop
- − make server
- − make daemon
- − make test
- − make sqlc
- − pnpm install
- − pnpm dev:web
- − pnpm dev:desktop
- − pnpm build
- − pnpm typecheck
- − pnpm lint
- − pnpm test
- − pnpm exec playwright test
- − pnpm ui:add badge
- − make check
- − pnpm-workspace.yaml
- − make worktree-env
- − make setup-worktree
- − make start-worktree
- − go vet
- − pnpm generate:reserved-slugs
- − pnpm ui:add <component>
- − pnpm ui:add @reui/<name>
- + npx @react-native-reusables/cli@latest add <name>
- + task:progress
- + pnpm view <pkg> dist-tags
- + pnpm exec expo install <pkg>
- + pnpm add <pkg>
- + git check-ignore -v <dir>/<file>
- + git ls-files <dir>
- + git status
Section tags
8 shared · 5 only in A · 5 only in B- − test
- − code-style
- − types
- − security
- − database
- + build
- + architecture
- + dependencies
- + deployment
- + monorepo
- setup
- lint-format
- testing-strategy
- git-pr
- api
- ui
- do-not
- agent-behaviour
Line diff
multica-ai/multica · CLAUDE.md
@@ −1 @@
1# CLAUDE.md
2
3Guidance for Claude Code when working in this repository. Keep this file short and authoritative: rules here should be hard to infer from code or easy to get wrong.
4
5## Conventions
6
7The source of truth for code naming, i18n glossary, and Chinese product voice is:
8
9- `apps/docs/content/docs/developers/conventions.mdx`
10- `apps/docs/content/docs/developers/conventions.zh.mdx`
11
12Read it before editing translations in `packages/views/locales/`, naming routes/packages/files/DB columns/types, or writing Chinese UI/docs copy. Do not rely on `packages/views/locales/glossary.md`; it is only a redirect stub.
13
14## Project Shape
15
16Multica is an AI-native task management platform for small teams, with agents as first-class assignees that can own issues, comment, and change status.
17
18- `server/`: Go backend, Chi router, sqlc, gorilla/websocket.
19- `apps/web/`: Next.js App Router.
20- `apps/desktop/`: Electron desktop app.
21- `apps/mobile/`: Expo / React Native iOS app. Read `apps/mobile/CLAUDE.md` before touching it.
22- `packages/core/`: headless business logic, API client, React Query hooks, Zustand stores.
23- `packages/ui/`: atomic UI components only.
24- `packages/views/`: shared business pages/components for web and desktop.
25- `packages/tsconfig/`: shared TypeScript config.
26
27Shared packages export raw `.ts` / `.tsx` and are compiled by consuming apps. Dependency direction is `views -> core + ui`; `core` and `ui` must stay independent.
28
29## State Rules
30
31Keep server state and client state separate.
32
33- TanStack Query owns server state: issues, users, workspaces, inbox, agents, members, and anything fetched from the API.
34- Zustand owns client/view state: filters, drafts, modals, tab layout, and navigation history. Current workspace identity is route-driven; platform stores/singletons may mirror slug/id only for headers, persistence namespaces, and reconnects.
35- Shared Zustand stores live in `packages/core/`, never in `packages/views/` or app directories.
36- React Context is for platform plumbing only, such as `WorkspaceIdProvider` and `NavigationProvider`.
37- Only auth/workspace stores may call `api.*` directly. Other server interaction belongs in queries/mutations.
38- Workspace-scoped query keys must include `wsId`.
39- Optimistic updates only when ALL hold: outcome locally predictable, user stays on the same screen (no navigation), failure is rare, rollback is trivial. Canonical: status/assignee/toggle field patches — patch determinate caches, roll back on failure, invalidate uncertain projections on settle.
40- Flows that navigate or confirm (create, delete, leave) must await the server before navigating or cleaning up; never optimistically remove an entity from cache.
41- Chat/message send uses the pending-message pattern: render immediately with a visible pending state and retry on failure, not silent optimism.
42- WebSocket events invalidate or patch Query cache for server data. They must never mirror server payload data into Zustand; clearing client-owned pointers (active session, selection, current workspace) is allowed only with a single responder and a self-initiated guard when this client can cause the event.
43- Persist durable preferences/drafts/layout. Do not persist server data or ephemeral UI state.
44- Zustand selectors must return stable references. Do not return freshly allocated objects/arrays from selectors without shallow comparison.
45- Hooks that need workspace context should accept `wsId`; do not call `useWorkspaceId()` internally unless the hook is guaranteed to run under the provider.
46
47## Package Boundaries
48
49These are hard constraints:
50
51- `packages/core/`: no `react-dom`, `localStorage` (use `StorageAdapter`), `process.env`, or UI libraries.
52- `packages/ui/`: no `@multica/core` imports and no business logic.
53- `packages/views/`: no `next/*`, no `react-router-dom`, no stores. Use `NavigationAdapter`, `useNavigation()`, and `<AppLink>`.
54- `apps/web/platform/`: only place for Next.js navigation/platform APIs.
55- `apps/desktop/src/renderer/src/platform/`: only place for `react-router-dom` navigation wiring.
56- Every workspace under `apps/` and `packages/` must declare directly imported external packages in its own `package.json`.
57- Shared dependencies use `catalog:` from `pnpm-workspace.yaml`; `apps/mobile/` pins Expo/React Native related versions directly.
58
59## Sharing Rules
60
61Web and desktop share business logic, hooks, stores, components, and views through `packages/core/`, `packages/ui/`, and `packages/views/`.
62
63If the same logic exists in both web and desktop, extract it unless it depends on platform APIs:
64
651. Next.js, Electron, or router APIs stay in the app/platform layer.
662. Headless logic belongs in `packages/core/`.
673. Shared UI or business views belong in `packages/views/`.
684. Shared primitives belong in `packages/ui/`.
69
70Mobile is independent. It may import types and pure functions from `@multica/core`, with `import type` for types, but owns its UI, state, hooks, providers, i18n, React version, build pipeline, and release cadence.
71
72## Commands
73
74Use the repo scripts as the source of truth. Common commands:
75
76```bash
77make dev # auto-setup and start the app
78make start # start backend + frontend
79make stop # stop app processes for this checkout
80make server # run Go server only
81make daemon # run local daemon
82make test # Go tests
83make sqlc # regenerate sqlc code after SQL changes
84pnpm install
85pnpm dev:web
86pnpm dev:desktop
87pnpm build
88pnpm typecheck
89pnpm lint
90pnpm test # TS/Vitest tests through Turborepo
91pnpm exec playwright test
92pnpm ui:add badge # shadcn/Base UI component into packages/ui
93```
94
95Worktrees share one PostgreSQL container and get isolated DB names/ports via `.env.worktree`. `make dev` auto-detects this. For manual setup use `make worktree-env`, `make setup-worktree`, and `make start-worktree`. `pnpm dev:desktop` additionally self-isolates per worktree (its own renderer port + app name) automatically, independent of `.env.worktree`.
96
97CI runs Node 22, Go 1.26.1, and a `pgvector/pgvector:pg17` PostgreSQL service.
98
99## Database and Migration Rules
100
101These are hard requirements for every new or modified database design and production migration:
102
103- Do not add database foreign keys (`FOREIGN KEY` / `REFERENCES`), cascading deletes, or cascading updates. Resolve relationships, validation, and dependent cleanup explicitly in application code. Use an application transaction when cleanup and the parent operation must commit or roll back atomically.
104- Every index created by a migration must use `CREATE INDEX CONCURRENTLY` or `CREATE UNIQUE INDEX CONCURRENTLY`, including indexes on newly created tables. PostgreSQL rejects concurrent index creation inside a transaction or a multi-command string, so keep each concurrent index build in its own single-statement migration file. The repository migration runner executes migration files outside an explicit transaction to support this.
105
106## Coding Rules
107
108- TypeScript strict mode is enabled; keep types explicit.
109- Go follows standard conventions: `gofmt`, `go vet`, checked errors.
110- Code comments must be English.
111- Prefer existing patterns/components over new parallel abstractions.
112- Avoid broad refactors unless required by the task.
113- For internal, non-boundary code, do not add compatibility layers, fallback paths, dual writes, legacy adapters, or temporary shims unless explicitly requested.
114- API boundaries are different: installed desktop clients can talk to newer backends, so response parsing must follow the API compatibility rules below.
115- If a flow or API is being replaced and the product is not live, prefer removing the old path instead of preserving both.
116- New global pre-workspace routes must be a single word (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Do not add hyphenated root routes like `/new-workspace`.
117- Reserved slugs live in `server/internal/handler/reserved_slugs.json`. Edit it, run `pnpm generate:reserved-slugs`, and commit the generated `packages/core/paths/reserved-slugs.ts`.
118- When changing CLI commands/flags, API fields, or product behavior documented by built-in skills under `server/internal/service/builtin_skills/*`, update the relevant `SKILL.md` and `references/*-source-map.md` in the same PR.
119
120## API Compatibility
121
122Frontend code must survive backend response drift, especially in installed desktop builds.
123
124- Parse API JSON with `parseWithFallback` in `packages/core/api/schema.ts` and a zod schema. Do not cast network JSON to `T`.
125- Endpoint responses consumed by UI logic must pass through a schema before returning.
126- Downstream UI should optional-chain and default fields defensively.
127- Prefer explicit boolean checks (`=== true`) over truthy/falsy checks on server fields.
128- Do not pin critical affordances to one backend boolean; combine signals when possible.
129- Server-driven enum switches need a `default` branch.
130- When adding or changing an endpoint, add/update the schema and include a malformed-response test.
131
132## Backend UUID Rules
133
134In `server/internal/handler/`, always know where a UUID came from before using it in write queries.
135
136- Resource path params that may be UUIDs or human-readable IDs must be resolved through loaders such as `loadIssueForUser`, `loadSkillForUser`, `loadAgentForUser`, or `requireDaemonRuntimeAccess`; subsequent writes use the resolved `entity.ID`.
137- Pure UUID inputs from request boundaries use `parseUUIDOrBadRequest(w, s, fieldName)` and return immediately on `ok=false`.
138- Trusted UUID round-trips from sqlc results or test fixtures use `parseUUID(s)`, which panics on invalid input.
139- Outside handlers, `util.ParseUUID(s) (pgtype.UUID, error)` is the safe variant; always check the error.
140
141## Web/Desktop Features
142
143When adding a shared page or feature for web and desktop:
144
1451. Put the page/component in `packages/views/<domain>/`.
1462. Add platform wiring in both `apps/web/app/` and the desktop router, unless the desktop flow is a transition overlay.
1473. Use `useNavigation().push()` or `<AppLink>` in shared code.
1484. Use shared guards/providers such as `DashboardGuard` from `packages/views/layout/`.
1495. Keep platform-only UI in the app or inject it through props/slots.
1506. Hooks that need workspace context should accept `wsId`.
151
152CSS for web/desktop is shared from `packages/ui/styles/`. Use semantic tokens such as `bg-background` and `text-muted-foreground`; avoid hardcoded Tailwind colors and duplicated base styles.
153
154## Desktop Rules
155
156Desktop routing has three categories:
157
158- Session routes: workspace-scoped tab destinations such as `/:slug/issues`.
159- Transition flows: pre-workspace one-shot actions such as create workspace or accept invite. These are `WindowOverlay` state, not routes.
160- Error/stale states: stale workspace tabs should auto-heal by dropping stale tab groups, not render desktop error pages.
161
162More desktop constraints:
163
164- New pre-workspace desktop flows register a `WindowOverlay` type in `stores/window-overlay-store.ts`; do not add them to `routes.tsx`.
165- `setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` mirrors the active route for headers, storage namespaces, and reconnects; workspace route layouts own setting it.
166- Code that leaves workspace context must call `setCurrentWorkspace(null, null)` explicitly.
167- Workspace delete must await the server before navigation/cleanup. Workspace leave currently clears/navigates before mutation only to avoid the `member:removed` realtime race; treat that as known debt, not a reusable pattern.
168- Cross-workspace navigation must go through the navigation adapter so it can call `switchWorkspace(slug, targetPath)`.
169- Full-window desktop views outside the dashboard shell must mount `<DragStrip />` from `@multica/views/platform` as the first flex child. Interactive controls in the top 48px need `WebkitAppRegion: "no-drag"`.
170
171## Mobile Rules
172
173Read `apps/mobile/CLAUDE.md` before touching `apps/mobile/`. It contains the mandatory pre-flight process, import limits, parity rules, tech stack, UI rules, data helpers, realtime strategy, and mobile release flow.
174
175Root-level reminders:
176
177- Mobile shares only `@multica/core` types and pure functions.
178- Mobile must match web/desktop product semantics: counts, permissions, enums/transitions, and data identity.
179- Mobile may differ in UI/interaction when the phone context requires it.
180
181## UI Rules
182
183- Prefer shadcn/Base UI components over custom implementations. Add them with `pnpm ui:add <component>` from the repo root.
184- The Pro `@reui` registry is configured in `packages/ui/components.json`; add items with `pnpm ui:add @reui/<name>` and answer `n` to every overwrite prompt so local component customizations survive. It reads `REUI_LICENSE_KEY` from the environment — agents get it from their Multica agent environment, humans export it in their own shell. Never write the key into a repo file.
185- ReUI ships source, not a dependency: route the vendored output to our layout (new primitives to `packages/ui/components/ui/`, compositions to `packages/views/<domain>/`) and rewrite it to our conventions before committing.
186- Use design tokens and semantic classes; avoid hardcoded colors.
187- Do not introduce extra local state unless the design requires it.
188- Handle overflow, long text, scrolling, alignment, and spacing deliberately.
189- If a component is identical between web and desktop, it belongs in a shared package.
190
191## Testing
192
193Tests follow the code:
194
195| What is tested | Location |
196| --- | --- |
197| Shared business logic, stores, queries, hooks | `packages/core/*.test.ts` |
198| Shared UI components, pages, forms, modals | `packages/views/*.test.tsx` |
199| Platform wiring such as cookies, redirects, search params | `apps/web/*.test.tsx` or `apps/desktop/` |
200| End-to-end flows | `e2e/*.spec.ts` |
201| Backend | `server/` Go tests |
202
203Rules:
204
205- Never test shared component behavior in an app test file.
206- `packages/views/` tests must not mock `next/*` or `react-router-dom`.
207- Mock `@multica/core` stores with the Zustand callable-store shape (`selectorFn` plus `getState`).
208- Mock `@multica/core/api` for API calls.
209- E2E tests should use `TestApiClient` for setup/teardown.
210- Prefer writing the failing test in the correct package before implementation when the change is behavioral.
211- Default tests must never resolve or execute user-installed agent CLIs. Pass a test-created fake executable path or a test-created missing path to agent subprocess code.
212- Real-agent smoke tests belong behind the `agentintegration` build tag and must check `MULTICA_RUN_REAL_AGENT_SMOKE=1` before executable lookup or account access.
213- Run an explicitly authorized real-agent smoke test with `(cd server && MULTICA_RUN_REAL_AGENT_SMOKE=1 go test -tags=agentintegration ./pkg/agent -run '<test-name>' -count=1 -v)`. This command may access an authenticated account and consume quota.
214- When adding a default agent command, add it to `scripts/agent-cli-command-names.txt`; the normal Linux/macOS test entry points fail on ambient agent CLI execution.
215
216## Verification
217
218For code changes, run the narrowest useful checks while iterating, then run broader verification when risk justifies it or when asked.
219
220Useful checks:
221
222```bash
223pnpm typecheck
224pnpm test
225make test
226pnpm exec playwright test
227make check
228```
229
230Do not claim verification passed unless you ran it. If you skip checks because the change is docs-only or the user asked not to run them, say so.
231
232## Commits and Releases
233
234- Commits should be atomic and use conventional prefixes: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
235- A production deployment requires a CLI release tag on `main`: create `v0.x.x`, push it, and let `release.yml` publish binaries and the Homebrew tap.
236- Bump patch by default unless the user specifies a version.
237
238## Domain Reminders
239
240- All queries filter by `workspace_id`; membership gates access; `X-Workspace-ID` selects the workspace.
241- Issue assignees are polymorphic: `assignee_type` plus `assignee_id` can reference a member or an agent.
242
multica-ai/multica · apps/mobile/CLAUDE.md
@@ +1 @@
1# Mobile App Rules (apps/mobile/)
2
3For cross-app sharing rules, see the root `CLAUDE.md` *Sharing Principles* section. This file documents the locked tech-stack baseline and the few mobile-specific rules — so AI doesn't suggest outdated alternatives.
4
5## What mobile may import from `packages/`
6
7- `import type` from `@multica/core/types/*` (zero runtime coupling)
8- Pure functions from `@multica/core/`
9
10Everything else, mobile writes its own.
11
12## Pre-flight — before you write any code
13
14For any new mobile feature / screen / interaction, complete the three steps below in order. **Skipping any step = no code yet** (read-only investigation and answering questions are exempt). This section overrides every other rule in this file.
15
16### 1. Read the real web/desktop implementation
17
18Until you can name the relevant code, don't reason from "general experience":
19
20- `packages/views/<feature>/` — UI shape, information density
21- `packages/core/<feature>/{queries,mutations,ws-updaters}.ts` — endpoints, cache key shapes, optimistic patches, WS event coverage
22- Anything matching `*-display.ts` / `dedupe*` / `coalesce*` / `useMemo(() => transform(raw))` — preprocessing between backend and JSX
23
24List the **must-agree points**: counts, enums, permissions, cross-cache side effects (e.g. a status change must also refresh inbox), navigation flow. Missing one of these is how the 2026-05-09 inbox duplicate-dot incident happened.
25
26### 2. Show the user the interaction plan + parity points (≤30s to read)
27
28Include:
29
30- What you're about to build (one sentence)
31- The container / interaction you propose (after walking the iOS-native > RNR > ask waterfall in §UI components)
32- Mental-model parity points pulled from step 1 (example: "counts mirror `deduplicateInboxItems`")
33- What UI **must differ** and why (example: "web has a sidebar workspace switcher; mobile puts it in Settings — same switching semantics")
34- **Visual baseline check** (this is baseline, not polish): tab bar has icons, every screen has a title, multiple right-side row elements stack vertically, secondary text routes through a type-aware label; place a web screenshot next to a simulator screenshot
35
36### 3. Wait for an explicit "do it / go / start" before writing code
37
38"Yes / right / sounds good" ≠ permission to act. "How should we do X?" ≠ permission to act. Only an explicit imperative ("build X / change X / start") triggers code.
39
40> Detailed rules live downstream: must-agree details in §Behavioral parity; component waterfall in §UI components; data / mirroring rules in §Data layer helpers and §Realtime. Pre-flight is the gate; those are the references.
41
42## Behavioral parity with web/desktop
43
44Mobile is allowed to differ in **UI and interaction** — it's a phone, not a port. It is NOT allowed to differ in **product semantics**. Users should not get a different mental model of "what's there" depending on which client they open.
45
46**The four things that must agree:**
47
48- **Counts / visibility** — same N for the same filter, under identical pagination / coalescing rules.
49- **Permissions / access** — mirror the same logic web uses (from `packages/core`); don't re-derive from feel.
50- **State enums / transitions** — render every status / priority / inbox type / comment type, with a sensible fallback for unknown values (per "API Response Compatibility" in the root CLAUDE.md). Never silently drop a category.
51- **Data identity** — same `id`, same `slug`, same canonical fields. Don't invent ids or normalize differently.
52
53**When UI must diverge**, write at the divergence point what rule it's mirroring (point at the source function in `packages/core` or `packages/views`) and why mobile renders it differently. A future reader should be able to tell in 30 seconds that the divergence is intentional and find the web-side source of truth.
54
55### ⚠️ Incident (2026-05-09): inbox dedup missing — counts disagreed
56
57**Symptom**: Web sidebar showed "Inbox 1" while mobile rendered 3+ unread dots on the same workspace, same user, same moment.
58
59**Root cause**: Backend `GET /api/inbox` returns raw rows that include:
601. archived items, and
612. multiple inbox notifications per issue (a comment, a status change, and an assignment on the same issue each create one row).
62
63Web/desktop run those raw rows through `deduplicateInboxItems` (`packages/core/inbox/queries.ts`) before rendering and before counting unread:
641. filter `archived = true` out
652. group by `issue_id`, keep the newest in each group
663. sort by `created_at` desc
67
68Mobile's first cut rendered the raw list directly. So a single issue with 3 notifications showed as 3 rows with 3 unread dots, while web showed 1.
69
70**Fix**: mirror `deduplicateInboxItems` into `apps/mobile/lib/inbox-display.ts`, run mobile's inbox tab through it before rendering and before any counting.
71
72**Lesson — encode this into your reflexes when adding any new mobile screen that consumes a list endpoint**:
73
74> Before rendering an API list response, grep `packages/core/<domain>/queries.ts` and `packages/views/<domain>/components/*.tsx` for any preprocessing — `dedupe*`, `coalesce*`, `filter*`, `*-display.ts`, `useMemo(() => transform(raw))`. Mirror everything that runs between `useQuery` and the JSX in web/desktop. **Do not assume the backend returns "what should be displayed"** — it usually returns the raw cache shape, and the client is responsible for shaping it.
75
76This pattern repeats: timeline coalescing (`buildTimelineGroups`), inbox dedup, comment thread flattening, etc. Each one is a behavioral parity hazard if mobile skips it.
77
78## Tech-stack baseline
79
80Start minimal. Add to this list when actually adopted — do NOT pre-list libraries.
81
82- **Expo SDK 55**
83- **React Native 0.82**
84- **React 19.1** — whatever Expo SDK 55 ships. Pinned in `apps/mobile/package.json` directly, NOT via root `catalog:`.
85- **TypeScript** strict
86- **Expo Router 55** (file-based routing — version aligns with Expo SDK)
87- **NativeWind 4** + **Tailwind 3.4** — NativeWind 5 is unstable; stay on v4. (Note: web/desktop use Tailwind v4 — versions intentionally differ.)
88- **react-native-reusables (RNR)** — the shadcn equivalent for React Native. Uses NativeWind + RN-Primitives + CVA. Component API mirrors shadcn. **Phased adoption in progress — see `apps/mobile/docs/rnr-migration.md` for the canonical plan, three-tier classification, and Phase 0/1/2/3 status.**
89- **TanStack Query 5** — mobile owns its `QueryClient` with `AppState` focus listener + `NetInfo` online listener.
90- **Zustand** — mobile-local state only.
91- **expo-secure-store** — auth token persistence + theme preference (`light` / `dark` / `system`).
92
93When upgrading any of these, update this list.
94
95## UI components & theming
96
97The full plan, file inventory, and migration phases live in `apps/mobile/docs/rnr-migration.md`. The rules below are the durable ones that must survive after the migration completes — read this section first when working on any UI.
98
99### Hard rule — existing pattern first, defaults first, native waterfall
100
101Three principles govern every UI decision on mobile. They exist to fight the temptation to recreate things that already exist — which is exactly the trap that produced the current 21 hand-written components and 18 hand-rolled sheets.
102
103**Principle 1 — existing pattern first.** Before reaching for ANY new component (RNR add, hand-written primitive, new sheet container), grep the mobile codebase for an already-shipped pattern that does the same thing.
104
105- Building a row → grep `components/inbox/`, `components/issue/`, `components/project/` for an analogous list-row first.
106- Building a picker / sheet → check `components/issue/pickers/`, `components/project/pickers/` — there are 8+ pickers; one of them is probably the shape you need.
107- Building a status / priority / actor visual → `components/ui/status-icon.tsx`, `priority-icon.tsx`, `actor-avatar.tsx` already exist. Re-use, don't re-skin.
108- Composer / form / detail screen layout → `app/(app)/[workspace]/issue/[id]/`, `chat/`, `new-issue.tsx` — copy the structure, don't reinvent.
109
110If a working pattern exists, **import or copy-adapt it**. If it almost-fits but needs a small extension, extend the existing one (one PR) rather than fork a second variant. Only when no existing pattern fits, proceed to Principle 2.
111
112Why: every "I'll just write a fresh one" produced one of the 21 legacy components. The codebase already paid the cost of figuring out the iOS-correct shape for inbox rows, picker sheets, status icons — don't re-pay it.
113
114**Principle 2 — defaults first.** When you use any RNR component, accept its default variant, default size, default spacing, default palette. Do NOT add wrapper layers, "improved" defaults, or `variant="multicaCustom"` styles unless a concrete product need demands it. Reaching for shadcn defaults is correct; reaching for a hand-tuned version of them is the failure mode.
115
116**Principle 3 — iOS native > RNR > discuss.** When you need a new interaction, walk this waterfall in order, stop at the first hit:
117
1181. **iOS / RN ships a native API?** Use it directly. Don't wrap a `Modal` to mimic it.
119 - Text input prompt → `Alert.prompt`
120 - Confirm / destructive prompt → `Alert.alert`
121 - Action sheet (one-of-N) → `ActionSheetIOS.showActionSheetWithOptions`
122 - Date / time → `@react-native-community/datetimepicker` (already installed)
123 - Image / camera → `expo-image-picker` (already installed)
124 - Documents → `expo-document-picker` (already installed)
125 - Share → `Share.share` from `react-native`
126 - Haptics → `expo-haptics` (already installed)
1272. **RNR ships a matching component?** `npx @react-native-reusables/cli@latest add <name>`. Use the default variant/size/palette.
1283. **Neither.** **Stop and ask the user.** Don't silently hand-roll a replacement — that's exactly how the pre-migration legacy accumulated.
129
130### Component placement
131
132After deciding via the waterfall:
133
134- **Generic UI primitives** → `components/ui/`. Either RNR `add` output or hand-written with `cva` + `cn()` + semantic tokens + `@rn-primitives/*` building blocks.
135- **Domain UI** (anything mentioning issues, priorities, statuses, actors, agents, presence, projects, runs) → `components/<domain>/`. Composes primitives but isn't generic.
136
137Never copy the visual shape of an existing hand-written `components/ui/` component as a template if its RNR equivalent exists — most of them are pre-migration legacy. The migration doc tracks which files are legacy and which have been replaced.
138
139### Theming model — CSS variables + class-based dark mode
140
141- Source of truth for colors is `global.css` — CSS variables defined under `:root` (light) and `.dark:root` (dark). `tailwind.config.js` maps utilities like `bg-background` to `hsl(var(--background))`, so the same class name resolves to the right color in either mode automatically.
142- `darkMode: 'class'` (NOT media-query). We control the mode explicitly so the in-app Settings → Appearance picker (`light` / `dark` / `system`) can override the OS preference.
143- The mode is switched by NativeWind's `useColorScheme().setColorScheme(mode)`. Calling it sets the root class; every `bg-foo` / `text-foo` reactively rebinds to the new variable values. No manual className toggling, no re-render dance.
144- React Navigation (`expo-router`'s `Stack` headers, modal chrome, drawer) is themed separately by passing `NAV_THEME[isDarkColorScheme ? 'dark' : 'light']` into `ThemeProvider`. Source of `NAV_THEME` is `lib/theme.ts`, which mirrors `global.css` in TypeScript.
145- Persistence: the user's choice goes into `expo-secure-store` under the key `theme-preference` (values: `light` / `dark` / `system`). Loaded synchronously at app startup in `app/_layout.tsx` before the first paint; missing key defaults to `system`.
146- **When you change a CSS variable in `global.css`, also update `lib/theme.ts`.** They mirror each other. The RNR docs include a prompt template for this sync.
147
148### What this replaces (and what stays)
149
150- The old "Visual tokens" approach — hand-transcribed hex values in `tailwind.config.js` — is being **replaced** by the CSS-variable system above. Web tokens are still inspiration only; we do NOT import `packages/ui/styles/tokens.css` (Tailwind v3.4 vs v4 mismatch makes file sharing impractical; isolation is intentional).
151- The `cn()` helper at `lib/utils.ts` stays — RNR uses the same one.
152- The sheet rule from Lesson 6 below still applies. RNR ships `Dialog` and other modal primitives; use them for **new** sheets. The legacy `sheet-shell.tsx` (RN `<Modal presentationStyle="pageSheet">`) has been deleted — every long-list / search / form sheet now uses an Expo Router `presentation: "formSheet"` route, which instantiates iOS' `UISheetPresentationController` for native grabber, detents, and spring drag physics.
153
154## Build & release
155
156- **Main CI** (`.github/workflows/ci.yml`) excludes mobile via `--filter='!@multica/mobile'`. Mobile failures do NOT block web/desktop PRs.
157- **Mobile verify** (`.github/workflows/mobile-verify.yml`): triggered on `apps/mobile/**` or `packages/core/types/**` changes — runs typecheck/lint/test only, no IPA build.
158- **Mobile release** (`.github/workflows/mobile-release.yml`): triggered by `mobile-v*.*.*` tag → `eas build` + `eas submit`.
159- **OTA** — EAS Update for JS-only fixes that don't change the runtime version. Manual / on-demand push to preview/production channels.
160
161Mobile release cadence is decoupled from main `v*.*.*` tags (server / CLI / desktop).
162
163## Realtime / WebSocket strategy
164
165Mobile uses the same WS server protocol as web/desktop, but mounts subscriptions differently. The rules below exist because mobile-specific constraints (cellular data cost, AppState lifecycle, per-screen unmount cleanup, smaller cache surface) make a direct port of web's pattern wrong.
166
167### Three-layer stack
168
169```
170Layer 1 ws-client.ts — single socket, no React. Exponential
171 backoff with full jitter. Three-state
172 lifecycle (idle / active / paused) so
173 the provider can pause on background
174 and resume on foreground without
175 racing the auto-reconnect timer.
176Layer 2 realtime-provider.tsx — owns the WSClient. Mounts/unmounts on
177 auth + workspace + AppState + NetInfo
178 changes. Exposes useWSClient().
179Layer 3 use-<feature>-realtime.ts — per-feature subscriptions. Translate
180 events → cache mutations.
181```
182
183Layer 3 is what changes per feature; layers 1 and 2 are infrastructure and shouldn't be edited when adding event coverage.
184
185### Mount strategy: list-level global, per-record per-screen
186
187Mobile **does NOT use a single centralized `useRealtimeSync` hook** like `packages/core/realtime/use-realtime-sync.ts`. That pattern is fine on web (one tab = one mount, lives forever) but on mobile it gets in the way: most events care about a single record (one issue's comments, one chat session's messages), and the hook needs to know which record without prop-drilling.
188
189Two mount tiers:
190
191- **Listing-level (always-on for the workspace session)** — mount inside the `<RealtimeSubscriptions />` component in `app/(app)/[workspace]/_layout.tsx`. These don't take parameters; they patch caches keyed only on `wsId`. Examples: `useInboxRealtime`, `useMyIssuesRealtime`. Both run from the moment the user enters a workspace until they leave it, regardless of which tab is foregrounded.
192
193- **Per-record (mounted with id, cleans up on unmount)** — mount inside the screen that owns the record, parameterized by the id from the route. Example: `useIssueRealtime(id, () => router.back())` in `issue/[id].tsx`. The hook filters every event by `payload.issue_id === id` and only patches the current issue's caches. When the user navigates away the `useEffect` cleanup unsubscribes all listeners, so a backgrounded screen doesn't keep mutating caches it no longer owns.
194
195Don't mount a per-record hook globally to "just be safe" — every filter call on every event then runs N times where N is the number of issues a user has ever opened in this session.
196
197### Patch over invalidate (cellular-data rule)
198
199When a WS payload contains the full updated object, **patch** the cache (`setQueryData` / `setQueriesData`). Only fall back to **invalidate** when:
200
2011. The payload is just an id (we don't know the full new shape — e.g., `issue:created` with no scope context).
2022. The cache shape doesn't match what we can patch (e.g., multi-key scope-filtered lists where we'd have to predict membership).
2033. The event is rare enough that the extra refetch isn't a real cost (e.g., `issue:deleted` on a list that was about to invalidate anyway).
2044. After a reconnect, where we may have missed events while disconnected.
205
206Web is fine to invalidate generously because most users are on broadband; mobile users on cellular pay for each refetch. A `setQueryData` is free; an `invalidateQueries` is a network roundtrip per affected query key.
207
208### Mobile-owned updaters (don't import `packages/core/issues/ws-updaters.ts`)
209
210Mobile has its own `apps/mobile/data/realtime/issue-ws-updaters.ts` even though web has a near-identical file. **Do not import web's updaters into mobile.** Two reasons:
211
2121. **Key-factory binding.** Web's updaters reference `issueKeys` from `packages/core/issues/queries.ts` — a different runtime instance from mobile's `apps/mobile/data/queries/issue-keys.ts`. TanStack Query compares keys structurally so it *appears* to work, but binding cache mutation to a foreign key factory invites silent drift the moment either side adjusts its key shape (renames a segment, adds a discriminator).
2132. **Cache-shape divergence.** Mobile has simpler caches: flat `Issue[]` for my-issues (web has status-bucketed); no children subtree (web does); no label-byIssue cache (web does). Web's updaters carry conditional dead-code for paths mobile doesn't have, and mobile would silently no-op on web shapes that don't exist locally.
214
215When the same logic needs to exist on both sides, copy the design — not the import. Document the mirror at the top of the mobile file (see `issue-ws-updaters.ts` for the pattern).
216
217### Event-always-wins (optimistic conflict policy)
218
219Mutations like `useUpdateIssue` apply an optimistic patch to the detail cache, then the server processes the request and broadcasts `issue:updated`. If a separate WS event (from another client / another user / an agent) arrives between the optimistic patch and the mutation response, the WS handler overwrites the optimistic state with the server's authoritative state. Brief UI flicker is acceptable; correctness wins.
220
221**Do not** add timestamp-comparison logic to "protect" the optimistic state — the server is the truth and the user benefits from seeing real changes immediately. If a specific event proves problematic in practice, add the gate at that point, not by default.
222
223### Reconnect handling
224
225Each hook registers a single `ws.onReconnect(cb)` that invalidates **only the queries it owns**:
226
227| Hook | Invalidates on reconnect |
228|---|---|
229| `useInboxRealtime` | `inboxKeys.list(wsId)` |
230| `useMyIssuesRealtime` | `issueKeys.myAll(wsId)` |
231| `useIssueRealtime(id)` | `issueKeys.detail(wsId, id)` + `issueKeys.timeline(wsId, id)` |
232
233No global "invalidate everything on reconnect" sweep. The fanout would be every screen the user has ever visited in this session refetching simultaneously — wasteful on cellular and prone to rate-limiting the server in low-signal areas where reconnects happen frequently.
234
235### Cross-cutting cache patches across features
236
237Some events legitimately need to mutate a foreign feature's cache. The
238canonical example: `issue:updated` changing an issue's status must also
239update the StatusIcon shown on the matching inbox row, and `issue:deleted`
240must strip every inbox row pointing at the dead issue.
241
242The pattern:
243
2441. **The feature whose cache is being patched owns the updater.** Example:
245 `apps/mobile/data/realtime/inbox-ws-updaters.ts` exports
246 `patchInboxIssueStatus` and `dropInboxItemsByIssue` — they live with
247 inbox, not with issues, because they read `inboxKeys.list(wsId)`.
2482. **That feature's realtime hook subscribes to the foreign event.**
249 `use-inbox-realtime.ts` subscribes to `issue:updated` and `issue:deleted`
250 alongside the `inbox:*` events. The issue-realtime hook does NOT know
251 that inbox cares.
2523. **Mirror web's wiring.** Web's `packages/core/inbox/ws-updaters.ts` has
253 the same handlers; mobile copies the design. Behavioral parity hazard:
254 without these the mobile inbox row keeps showing the prior status (or
255 404s on tap if the issue is gone) while web users see the change live.
256
257If you find yourself reaching across features in `use-issues-realtime` to
258patch something else, you have the inversion: move the updater to the
259patched feature and subscribe there.
260
261### Adding new event coverage — recipe
262
2631. **Read the payload.** Find the event in `@multica/core/types/events.ts`. Note the fields; decide if patch is possible (full object) or invalidate is required (just an id).
2642. **Mirror, don't import.** If web has an updater for this event in `packages/core/<feature>/ws-updaters.ts`, copy the design into `apps/mobile/data/realtime/<feature>-ws-updaters.ts`. Adapt to mobile's actual cache shapes — don't carry web's bucket/children/childProgress dead-code if mobile doesn't have those caches.
2653. **Subscribe in a hook.** Either extend an existing `use-<feature>-realtime.ts` or create a new one. Filter by id at the top of each handler so per-record hooks ignore unrelated events.
2664. **Mount it.** Listing-level → add to `<RealtimeSubscriptions />` in workspace `_layout.tsx`. Per-record → add to the owning screen's body, parameterized by the route id.
2675. **Add reconnect invalidate.** Single `ws.onReconnect()` call scoped to the hook's own keys.
2686. **Verify cross-client.** Open the affected screen on mobile, change the same record from a second client (web or another device), confirm mobile updates within ~500ms without pull-to-refresh.
269
270If a new event has no consumer on mobile (e.g., `subscriber:added` when mobile doesn't render subscriber lists yet), **don't subscribe**. Mounting a listener with no UI consumer adds CPU on every fire for zero user benefit.
271
272## Data layer helpers (use these — don't recreate them)
273
274Common boilerplate is wrapped. New code that reinvents these helpers is a
275review-block, both because it makes the codebase inconsistent AND because
276the helpers encode subtle correctness rules (signal forwarding, schema
277fallback, sync-before-await ordering, type-safe payloads).
278
279### Three rails that every feature must follow
280
2811. **Logic mirrors web/desktop.** See §Pre-flight step 1 at the top of
282 this file. Restating the data-contract half here: endpoints, request
283 bodies, response schemas, optimistic patches, and cache key prefixes
284 all match web verbatim. UI / interaction can diverge freely per
285 §Behavioral parity.
286
2872. **Use the existing components — no new primitives.** Walk the
288 `iOS native > RNR > discuss` waterfall in §UI components. If RNR ships
289 it, `npx @react-native-reusables/cli@latest add <name>`. If iOS ships
290 it (Alert / ActionSheetIOS / Haptics / share / picker), use it directly.
291 If neither has it AND it's a single-screen need, inline compose with
292 `<Pressable>` + `<Text>` + tokens. **Do NOT create a new generic
293 primitive in `components/ui/` for one or two callers** — the migration
294 doc lists "21 hand-written components" as exactly the trap we're
295 escaping. Threshold for a new primitive is three callers AND no
296 RNR/iOS-native alternative.
297
2983. **Use the wrapped request / WS layer.** See the helper map below.
299
300### API client: `fetchValidated` + `fetchValidatedWith`
301
302`apps/mobile/data/api.ts` exposes two private helpers on `ApiClient` that
303collapse the fetch + parseWithFallback envelope. **Every new read-side
304method that returns a typed body must use them.**
305
306| Helper | When to use | Shape |
307|---|---|---|
308| `this.fetchValidated(path, schema, fallback, opts?)` | GET endpoints | One-liner method body — see `getMe`, `listInbox`, `getNotificationPreferences` |
309| `this.fetchValidatedWith(path, schema, fallback, init, opts?)` | Any HTTP method (PATCH / PUT / POST) whose response is consumed | Carries the body via `init.body` + method; signal forwarding handled |
310| `this.fetch<T>(path, init?)` directly | Writes whose response is `{ count }` / `void` / not consumed by UI logic | Only here is a raw `as T` acceptable, because the value never reaches a render path |
311
312Rules:
313- The fallback object MUST match the success type exactly so downstream
314 code never has a partial value (see `EMPTY_USER` / `EMPTY_INBOX_LIST`
315 pattern in `apps/mobile/data/schemas.ts`).
316- The `endpoint` label is for telemetry — defaults to the path; override
317 only when the path has dynamic segments and you want stable groupings
318 (`GET /api/issues/:id` not `GET /api/issues/abc-123`).
319- Migration is progressive: not every legacy method is converted yet.
320 Adding a new method? Use the helpers. Touching an old method that
321 isn't using them? Convert it as part of the same PR.
322
323### Query / mutation factory pattern
324
325Every workspace-scoped feature exposes a key factory in
326`apps/mobile/data/queries/<feature>.ts`:
327
328```ts
329export const inboxKeys = {
330 all: (wsId: string | null) => ["inbox", wsId] as const,
331 list: (wsId: string | null) => [...inboxKeys.all(wsId), "list"] as const,
332};
333```
334
335Three-segment shape matches web (`packages/core/inbox/queries.ts`).
336Reasons:
337
338- TQ does prefix matching by default — `invalidateQueries({ queryKey:
339 inboxKeys.all(wsId) })` invalidates the list AND any future sub-keys
340 (e.g. a `detail(id)`) under the same prefix. Use `.all` to clear a
341 workspace cleanly, `.list` to target the list specifically.
342- Cross-platform mental-model parity: a reader switching between mobile
343 and web finds the same key shape.
344- Stops bare `["inbox", wsId]` strings from spreading. Grep
345 `\["inbox"` in this codebase should only hit the factory file.
346
347Mutations import the factory and use `inboxKeys.list(wsId)` everywhere —
348never inline strings.
349
350### WS layer: `ws.on<E>()` + `useWSSubscriptions`
351
352Two helpers replace ~20 lines of boilerplate per realtime hook:
353
3541. **`ws.on<E extends WSEventType>(event, handler)`** — the handler's
355 `payload` parameter is auto-typed to `WSEventPayload<E>`. **Do not
356 add `as XxxPayload` casts at handler bodies** — they're redundant
357 and (worse) silently hide drift if `WSEventPayloadMap` shifts.
358 The cast is only acceptable when one handler covers multiple events
359 that don't share a typed common ancestor (see `onTaskEvent` in
360 `use-issue-realtime.ts` — `task:progress` has no formal payload).
3612. **`useWSSubscriptions(setup, deps)`** in
362 `apps/mobile/lib/use-ws-subscriptions.ts` — wraps the
363 `if (!ws || !wsId) return; useEffect + cleanup` template. Setup
364 callback receives `(ws, wsId)`, returns the unsub array (or
365 `undefined` to short-circuit, e.g. when a per-record id is missing).
366
367Adding a new event type? Extend `packages/core/types/events.ts`:
368
3691. Add the event to the `WSEventType` union.
3702. Add the payload interface.
3713. Add the `WSEventType → payload` entry in `WSEventPayloadMap`.
372
373Forgetting step 3 means callers get `unknown` (loud — they have to
374narrow), not `any` (silent unsafe access). That's the safety net.
375
376### Synchronous setQueryData before `await cancelQueries`
377
378Optimistic mutations that flip state read by a UI element that's about
379to be in a navigation snapshot (the classic case: marking an inbox row
380read, then `router.push` to the issue) MUST call `setQueryData` in
381`onMutate` **before** `await qc.cancelQueries(...)`. The await yields
382one microtask; iOS captures the source-view snapshot during that gap and
383freezes the row in its unread style inside the slide-in transition.
384
385Lives inside the mutation, not the caller. See `useMarkInboxRead.onMutate`
386in `apps/mobile/data/mutations/inbox.ts` for the canonical example.
387
388### Checklist for a new feature
389
390Before opening a PR for a new screen / mutation / realtime hook:
391
3921. Grep `packages/core/<feature>/` for the web equivalent — endpoints,
393 key shape, optimistic patch shape. Mirror, don't invent.
3942. API methods → `fetchValidated` / `fetchValidatedWith` (or raw
395 `this.fetch` only for writes with no consumed response).
3963. Query key → factory in `data/queries/<feature>.ts`, 3-segment shape.
3974. Mutations → optimistic only when the post-state is locally predictable,
398 the user stays on the same screen, failure is rare, and rollback is
399 trivial. When that gate passes, use snapshot → patch → rollback +
400 settle invalidate, all keys via factory. Create/delete/navigate/confirm
401 flows await the server instead.
4025. Realtime → `useWSSubscriptions(setup, deps)`, typed `ws.on<E>()`,
403 per-event patching (no global invalidate) when payload carries the
404 full object.
4056. UI → waterfall (iOS native > RNR > inline compose). No new
406 `components/ui/` primitive unless three callers + RNR doesn't ship.
4077. Verify cross-client: change the same record from web and confirm
408 mobile updates within ~500ms without pull-to-refresh.
409
410## Lessons learned (encode into reflexes)
411
412These are real mistakes that have been made building the mobile shell. Each one cost time to find. Treat as enforceable rules, not suggestions.
413
414### 1. Install/upgrade any dependency: check `dist-tags` first
415
416Do NOT hardcode version numbers from memory. Run `pnpm view <pkg> dist-tags` to see `latest / sdk-XX / canary` and decide which tag to lock. For Expo packages (`expo-*` / `react-native-*` that Expo aligns), use `pnpm exec expo install <pkg>` — it queries Expo's dependency manifest and picks the SDK-compatible version. `pnpm add <pkg>` will silently install the npm `latest`, which often outpaces the SDK and breaks at runtime. Past mistakes: hardcoded `expo@~54.0.0` (latest was already `55.x`); installed `lucide-react-native@0.468` without checking React 19 peer compatibility.
417
418### 2. New source subdirectory: verify git tracking
419
420Every time you create a new source subdirectory under `apps/mobile/` (e.g. `data/`, `lib/foo/`, `components/inbox/`):
421
4221. Run `git check-ignore -v <dir>/<file>` immediately. The repo-root `.gitignore` has generic rules (`data/`, `build/`, `bin/`, `*.app`, `*.dmg`) that are intended for backend runtime/output dirs but will silently swallow mobile source.
4232. If a rule matches, add `!<dir>/` and `!<dir>/**` to `apps/mobile/.gitignore` (subtree override beats parent rule).
4243. After the commit lands, run `git ls-files <dir>` to confirm every file is tracked.
425
426This rule exists because `apps/mobile/data/` was once committed-but-not-tracked — 14 source files (ApiClient, all queries, all stores) were missing from the git tree even though `git status` was clean. Local builds worked because Metro reads the filesystem; CI / clones would have died.
427
428### 3. ApiClient capability list (4 must-haves)
429
430Mobile's fetch wrapper (`apps/mobile/data/api.ts`) MUST implement all four. Missing any of them is a bug, not a deferred polish item.
431
4321. **Zod `parseWithFallback` for response validation.** Strictly enforced by the root CLAUDE.md "API Response Compatibility" section and the "Type drift defense" section above. **Any new endpoint method that does `as T` on the response body is a bug.** Reuse schemas from `packages/core/api/schemas.ts` (pure Zod exports, on the mobile sharing whitelist); define mobile-side fallbacks for new endpoints in `apps/mobile/data/`.
433
4342. **`onUnauthorized` 401 callback.** The `ApiClientOptions.onUnauthorized` hook fires on every 401 and must be wired in `app/_layout.tsx` to: clear auth token, clear workspace store, clear TanStack Query cache, navigate to `/login`. Without it a session that expired server-side puts every subsequent request into a 401 loop and the user sees opaque "API error: 401" toasts on every screen. Use a `signingOutRef` to make the callback idempotent — multiple in-flight requests will all 401 simultaneously when a session expires.
435
4363. **`X-Request-ID` per request.** Generate a short random ID (`createRequestId()` in `apps/mobile/lib/request-id.ts`), send as `X-Request-ID` header. The same ID goes into client-side log lines so backend telemetry can be cross-referenced (server picks it up via the same header).
437
4384. **Structured request logger.** Two log lines per request: `[api] → METHOD path` (start, with `rid`) and `[api] ← STATUS path` (end, with `rid` + `duration`). Use `console.error` for 5xx, `console.warn` for 404s, `console.log` for success. Without this, debugging mobile API issues means staring at the React Native Network panel; with it, the dev console is self-explanatory and prod telemetry already comes structured.
439
440**What mobile correctly does NOT need (don't add these):** CSRF token (`X-CSRF-Token`), `credentials: "include"`, cookie reading. Mobile is Bearer-token auth, not cookie auth — the cookie attack surface that requires CSRF protection on web doesn't exist on mobile.
441
442### 4. Every read query must pass `signal` to fetch; api.ts always has a hard timeout
443
444**Symptom that triggered the rule (2026-05-11)**: Inbox screen sometimes returned to the foreground showing the FlatList pull-to-refresh spinner stuck indefinitely. List items were rendered underneath, but `isRefetching` never flipped back to `false`. Pull-to-refresh, navigating away, and re-opening the tab did not clear it.
445
446**Root cause**: `apps/mobile/data/api.ts`'s `fetch()` had no timeout, no `AbortController`, and no caller-`signal` plumbing. iOS suspends backgrounded apps within ~30 seconds and can silently kill in-flight network tasks (facebook/react-native#35384 — "iOS fetch() POST fails if called too soon, with app running in background"; facebook/react-native#38711 — "JS Timers don't fire when app is launched in background"). When the app foregrounded, the suspended fetch's Promise neither resolved nor rejected. TanStack Query saw an existing query still in `fetching` state and did NOT start a new fetch on invalidate — it just waited on the dead Promise forever. `isRefetching` stayed `true`, the FlatList spinner stayed spinning.
447
448**Rule, three parts (every one is required — partial fixes leave a footgun)**:
449
450**1. `api.ts` `fetch()` MUST have a hard timeout** (currently 30s; the `FETCH_TIMEOUT_MS` constant). Without this, a single suspended request can wedge a query indefinitely. Use a manual `AbortController` + `setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)` — **DO NOT** use `AbortSignal.timeout()`: Hermes throws `TypeError: AbortSignal.timeout is not a function` (facebook/react-native#42042). Same for `AbortSignal.any()` — Hermes does not implement it (livekit/livekit#4014). To combine the timeout signal with a caller-supplied signal, attach an `"abort"` event listener manually and forward to the inner controller.
451
452**2. Every read-side `api.ts` method MUST accept `opts?: { signal?: AbortSignal }` and pass it to `fetch()`**. Mutations don't need this (TanStack Query doesn't pass a signal to `mutationFn`). The pattern:
453```ts
454async listInbox(opts?: { signal?: AbortSignal }): Promise<InboxItem[]> {
455 return this.fetch<InboxItem[]>("/api/inbox", { signal: opts?.signal });
456}
457```
458Adding a new query-bound method without `opts` is a bug — the next person who writes a `queryFn` will silently drop the signal.
459
460**3. Every `queryFn` MUST forward the signal it receives from TanStack Query**. The official TanStack guide (tanstack.com/query/v5/docs/framework/react/guides/query-cancellation) states: "When a query becomes out-of-date or inactive, this `signal` will become aborted." The pattern:
461```ts
462queryOptions({
463 queryKey: [...],
464 queryFn: ({ signal }) => api.listInbox({ signal }),
465});
466```
467Forgetting the destructure (writing `() => api.listInbox()`) defeats every benefit of (1) and (2): TQ can't cancel hung requests when the user navigates away, and on workspace switch every stale request lives until its 30s timeout.
468
469**Verification**: After any change to `api.ts` or a new query addition, `grep -n "queryFn: () =>" apps/mobile/data/queries/` should return zero matches. Every `queryFn` should destructure `{ signal }`.
470
471**Why the wiring already in `data/query-client.ts` (focusManager + AppState, onlineManager + NetInfo) is not enough on its own**: focusManager triggers a *refetch attempt* when the app comes back to the foreground, but if the prior fetch promise is hanging, TQ won't start a new request — it'll keep waiting on the dead one. Only timeout + signal cancellation actually unwedges the query. The three pieces work together: signal lets TQ proactively cancel on staleness, timeout is the safety net when nothing else fires, focusManager is the "user came back, let's recheck" trigger.
472
473### 5. Modal container selection: match container to content, don't copy the first sheet
474
475The mobile codebase started with ~15 Modal sheets. They almost all copied the same shape (`Modal transparent fade` + hand-drawn `bg-black/40` backdrop + centered/bottom card with `maxHeight`). That shape is correct for **short action menus** (the earliest sheets), wrong for **everything else**. Once the pattern was established as "the mobile sheet style," subsequent sheets inherited it regardless of content — and inherited a different bug each time: keyboard squashing the card, `maxHeight: 380` clipping FlatLists on tall phones, `useSafeAreaInsets` returning 0 inside Modal so bottom content collides with the Home Indicator, etc.
476
477**Choose the container by content type, not by "what the last sheet did":**
478
479| Content shape | Container | Why |
480|---|---|---|
481| < 5 fixed actions, 1-2s stay, no keyboard | `Modal transparent` + bottom action card | Short, light, dim-backdrop tap-to-dismiss is correct here |
482| Yes/No or one-tap confirm | `Alert.alert` | Native, accessible, no custom UI |
483| One-of-N from a server-driven short list | `ActionSheetIOS.showActionSheetWithOptions` | Native iOS action sheet, no custom UI |
484| < 7 fixed picker options, no search | `Modal transparent` + small centered card | Same as action card, just centered |
485| Long list / search box / content view / form / anything with a keyboard | **Expo Router `presentation: "formSheet"` route** | Instantiates iOS `UISheetPresentationController`: native grabber, drag-dismiss with spring physics, stacked-card backdrop, detents — all UIKit-managed |
486| Multi-screen flow / route-level full modal | Expo Router `presentation: "modal"` | Full-page slide-up, has back-stack, swipe-dismiss, deep-linkable |
487
488**`SheetShell` is deleted.** It was a wrapper around RN core `<Modal presentationStyle="pageSheet">` which does NOT instantiate `UISheetPresentationController` — so it never had native grabber, stacked-card backdrop, or real spring physics. Every former SheetShell call site is now an Expo Router formSheet route.
489
490**Rules for adding a new formSheet route:**
491
4921. **File goes under the parent context** so the URL reads sensibly — issue-detail pickers under `app/(app)/[workspace]/issue/[id]/picker/<field>.tsx`; project pickers under `project/[id]/picker/<field>.tsx`; transient action sheets under `<context>/<noun>/actions.tsx`. The new-issue draft flow has its own `new-issue-picker/<field>.tsx` directory because routes can't share state with the modal that opened them — see the draft-store discussion below.
4932. **Register the Stack.Screen in `app/(app)/[workspace]/_layout.tsx`** using the shared `SHEET_OPTIONS` constant. Do NOT inline the config per screen — every picker-row sheet must look and feel identical (grabber, detents, corner radius). Isolated sheets that have no neighbour to be consistent with may override `sheetAllowedDetents` only (e.g. the `menu` sheet uses `"fitToContents"` because it's ≤ 5 fixed actions and the two-snap default would leave 60% blank).
4943. **Self-contained route bodies.** A picker route reads the record it needs from the TanStack Query cache (issue / project / timeline are already cached when the user gets there), calls its own mutation on submit, and `router.back()`s. No callbacks back up to a parent. The only legitimate exception is the new-issue draft flow, which uses `useNewIssueDraftStore` because the issue doesn't exist yet — there's nothing in cache to read.
4954. **Header is drawn inside the body**, not by the Stack. SHEET_OPTIONS sets `headerShown: false`; the body renders its own `<View>` with title + optional right action. The native Stack header on a formSheet creates a layout dance with the grabber that doesn't match iOS sheets.
496
497**SHEET_OPTIONS rationale (every value exists for a known bug or platform behavior):**
498
499- `presentation: "formSheet"` — the magic that hands the screen to `UISheetPresentationController`.
500- `sheetGrabberVisible: true` — the iOS native drag handle. Users don't discover the gesture without it.
501- `sheetAllowedDetents: [0.6, 0.95]` — explicit numeric detents. The ergonomic `"fitToContents"` is broken on iOS 26 + Expo 55 (expo/expo#42904 padding inconsistency, #42965 zero-size). Predictable two-snap presentation across every picker-row sheet is more important than shrink-wrapping; every formSheet that lives in a chip row (issue-detail / project-detail AttributeRow) uses these explicit detents so muscle memory carries across the row. Isolated sheets (no chip-row neighbour) override with `"fitToContents"` — see the workspace `menu` sheet for the canonical example.
502- `sheetCornerRadius: 20` — matches RNR card radius. Without this iOS uses a larger system default that's slightly out of sync with the rest of the app.
503- `contentStyle: { height: "100%" }` — safety net against the zero-size class of bugs above. Ensures the sheet body fills the allotted detent height.
504
505**Caveats that still apply:**
506
507- **Android falls back to a regular modal** — no rounded corners, no native drag. mobile/CLAUDE.md treats iOS as the primary target so this is acceptable, but document inline at the call site if a particular feature must work identically on both.
508- **A formSheet pushed from inside a `presentation: "modal"` route is supported** by Expo Router 55 / RN Screens 4, but the back gesture from the formSheet returns to the modal, not the underlying tab. This is the right UX for the new-issue draft flow (sheet dismisses back to the form), but check the navigation graph if you're adding a sheet under a non-obvious parent.
509
510**Carve-out — picker-row consistency wins over per-container optimisation:**
511
512The table above says "< 7 fixed picker options → centered card". That rule
513applies in isolation, but **breaks down when multiple pickers coexist in
514the same chip row** (issue-detail AttributeRow is the canonical case:
515status / priority / assignee / label / project / due-date all sit next
516to each other). Mixing centered cards (for status/priority, short
517fixed lists) with formSheet routes (for assignee/label/project, long
518lists) means the user gets two different gestures depending on which
519chip they tap — there's no muscle-memory carry-over.
520
521When you find yourself building a row like this, **use the formSheet
522route for every picker in the row**, even the ones a standalone
523centered card would handle fine. The cost is some empty space below
5245–7 short rows; the gain is uniform tap → slide-up-sheet +
525drag-down-to-dismiss behaviour across the whole row. Linear iOS /
526Things 3 / Apple Reminders all do this for the same reason.
527
528The centered-card pattern stays correct for **isolated short menus**
529(e.g. the chat-composer's "More" popover, the timeline's coalesce-
530expand) where there's no neighbour to be consistent with.
531
532### 6. Destructive swipe: reveal only, no auto-fire — always pair with haptic
533
534iOS Mail / Linear iOS / Things: leftward swipe reveals a red Archive
535button; the user **must tap it** to commit. The earlier mobile inbox
536swipe auto-fired on full drag past the threshold and "felt wrong" — no
537peek, easy to trigger by accident on a fast vertical scroll that
538catches some horizontal motion. There is no native UX that auto-commits
539a destructive action on swipe — match the platform standard.
540
541The rule:
542
543- `ReanimatedSwipeable` with `renderRightActions={<Pressable onPress={fireArchive} />}`.
544- **No `onSwipeableOpen` auto-fire.** Drag → reveals the action; release
545 past threshold → action stays revealed; tap action → commit; tap
546 outside or drag back → cancel.
547- One-shot `Haptics.impactAsync('medium')` when the drag crosses the
548 action width. Wire via `useAnimatedReaction(() => drag.value <= -ACTION_WIDTH, ...)`
549 + `runOnJS(Haptics.impactAsync)`. The shared-value reaction runs on
550 the UI thread; `runOnJS` bridges to the JS-only Haptics call.
551
552See `apps/mobile/components/inbox/swipeable-inbox-row.tsx` for the
553reference implementation. When adding a new swipe-to-action row
554elsewhere, copy that pattern; do not reinvent.
555
556### 7. Tier C domain components: opportunistic upgrade only — no silent rewrites
557
558Tier C in `apps/mobile/docs/rnr-migration.md` §4 names the domain UI
559files that stay where they are but need foundation upgrades
560(`ActorAvatar`, `StatusIcon`, `PriorityIcon`, `PresenceDot`, etc.).
561**You don't rewrite a Tier C file just because you're rendering it in
562your new feature.** That spreads scope and stalls feature PRs.
563
564Two rules:
565
5661. **Touch only what your PR needs to touch.** If `ActorAvatar` has
567 hardcoded `#71717a` and you're building an inbox feature that
568 *uses* `<ActorAvatar>`, leave the hex alone. Note it for a future
569 doc / cleanup PR.
5702. **Upgrade Tier C only when you're modifying that file for a
571 different real reason.** E.g. adding presence to chat header → you
572 were going to touch `<ActorAvatar>` anyway → fold the RNR-Avatar
573 migration + hex → token cleanup into the same PR.
574
575The pre-migration legacy persists because someone "while I'm in
576here…"-style touched 21 files in one PR; we don't do that anymore.
577Document any Tier C smells you spotted in the PR description as
578follow-ups; surface for a future grouped Tier C cleanup PR.
579
@@ −1 +1 @@
1−# CLAUDE.md
1+# Mobile App Rules (apps/mobile/)
22
3−Guidance for Claude Code when working in this repository. Keep this file short and authoritative: rules here should be hard to infer from code or easy to get wrong.
3+For cross-app sharing rules, see the root `CLAUDE.md` *Sharing Principles* section. This file documents the locked tech-stack baseline and the few mobile-specific rules — so AI doesn't suggest outdated alternatives.
44
5−## Conventions
5+## What mobile may import from `packages/`
66
7−The source of truth for code naming, i18n glossary, and Chinese product voice is:
7+- `import type` from `@multica/core/types/*` (zero runtime coupling)
8+- Pure functions from `@multica/core/`
89
9−- `apps/docs/content/docs/developers/conventions.mdx`
10−- `apps/docs/content/docs/developers/conventions.zh.mdx`
10+Everything else, mobile writes its own.
1111
12−Read it before editing translations in `packages/views/locales/`, naming routes/packages/files/DB columns/types, or writing Chinese UI/docs copy. Do not rely on `packages/views/locales/glossary.md`; it is only a redirect stub.
12+## Pre-flight — before you write any code
1313
14−## Project Shape
14+For any new mobile feature / screen / interaction, complete the three steps below in order. **Skipping any step = no code yet** (read-only investigation and answering questions are exempt). This section overrides every other rule in this file.
1515
16−Multica is an AI-native task management platform for small teams, with agents as first-class assignees that can own issues, comment, and change status.
16+### 1. Read the real web/desktop implementation
1717
18−- `server/`: Go backend, Chi router, sqlc, gorilla/websocket.
19−- `apps/web/`: Next.js App Router.
20−- `apps/desktop/`: Electron desktop app.
21−- `apps/mobile/`: Expo / React Native iOS app. Read `apps/mobile/CLAUDE.md` before touching it.
22−- `packages/core/`: headless business logic, API client, React Query hooks, Zustand stores.
23−- `packages/ui/`: atomic UI components only.
24−- `packages/views/`: shared business pages/components for web and desktop.
25−- `packages/tsconfig/`: shared TypeScript config.
18+Until you can name the relevant code, don't reason from "general experience":
2619
27−Shared packages export raw `.ts` / `.tsx` and are compiled by consuming apps. Dependency direction is `views -> core + ui`; `core` and `ui` must stay independent.
20+- `packages/views/<feature>/` — UI shape, information density
21+- `packages/core/<feature>/{queries,mutations,ws-updaters}.ts` — endpoints, cache key shapes, optimistic patches, WS event coverage
22+- Anything matching `*-display.ts` / `dedupe*` / `coalesce*` / `useMemo(() => transform(raw))` — preprocessing between backend and JSX
2823
29−## State Rules
24+List the **must-agree points**: counts, enums, permissions, cross-cache side effects (e.g. a status change must also refresh inbox), navigation flow. Missing one of these is how the 2026-05-09 inbox duplicate-dot incident happened.
3025
31−Keep server state and client state separate.
26+### 2. Show the user the interaction plan + parity points (≤30s to read)
3227
33−- TanStack Query owns server state: issues, users, workspaces, inbox, agents, members, and anything fetched from the API.
34−- Zustand owns client/view state: filters, drafts, modals, tab layout, and navigation history. Current workspace identity is route-driven; platform stores/singletons may mirror slug/id only for headers, persistence namespaces, and reconnects.
35−- Shared Zustand stores live in `packages/core/`, never in `packages/views/` or app directories.
36−- React Context is for platform plumbing only, such as `WorkspaceIdProvider` and `NavigationProvider`.
37−- Only auth/workspace stores may call `api.*` directly. Other server interaction belongs in queries/mutations.
38−- Workspace-scoped query keys must include `wsId`.
39−- Optimistic updates only when ALL hold: outcome locally predictable, user stays on the same screen (no navigation), failure is rare, rollback is trivial. Canonical: status/assignee/toggle field patches — patch determinate caches, roll back on failure, invalidate uncertain projections on settle.
40−- Flows that navigate or confirm (create, delete, leave) must await the server before navigating or cleaning up; never optimistically remove an entity from cache.
41−- Chat/message send uses the pending-message pattern: render immediately with a visible pending state and retry on failure, not silent optimism.
42−- WebSocket events invalidate or patch Query cache for server data. They must never mirror server payload data into Zustand; clearing client-owned pointers (active session, selection, current workspace) is allowed only with a single responder and a self-initiated guard when this client can cause the event.
43−- Persist durable preferences/drafts/layout. Do not persist server data or ephemeral UI state.
44−- Zustand selectors must return stable references. Do not return freshly allocated objects/arrays from selectors without shallow comparison.
45−- Hooks that need workspace context should accept `wsId`; do not call `useWorkspaceId()` internally unless the hook is guaranteed to run under the provider.
28+Include:
4629
47−## Package Boundaries
30+- What you're about to build (one sentence)
31+- The container / interaction you propose (after walking the iOS-native > RNR > ask waterfall in §UI components)
32+- Mental-model parity points pulled from step 1 (example: "counts mirror `deduplicateInboxItems`")
33+- What UI **must differ** and why (example: "web has a sidebar workspace switcher; mobile puts it in Settings — same switching semantics")
34+- **Visual baseline check** (this is baseline, not polish): tab bar has icons, every screen has a title, multiple right-side row elements stack vertically, secondary text routes through a type-aware label; place a web screenshot next to a simulator screenshot
4835
49−These are hard constraints:
36+### 3. Wait for an explicit "do it / go / start" before writing code
5037
51−- `packages/core/`: no `react-dom`, `localStorage` (use `StorageAdapter`), `process.env`, or UI libraries.
52−- `packages/ui/`: no `@multica/core` imports and no business logic.
53−- `packages/views/`: no `next/*`, no `react-router-dom`, no stores. Use `NavigationAdapter`, `useNavigation()`, and `<AppLink>`.
54−- `apps/web/platform/`: only place for Next.js navigation/platform APIs.
55−- `apps/desktop/src/renderer/src/platform/`: only place for `react-router-dom` navigation wiring.
56−- Every workspace under `apps/` and `packages/` must declare directly imported external packages in its own `package.json`.
57−- Shared dependencies use `catalog:` from `pnpm-workspace.yaml`; `apps/mobile/` pins Expo/React Native related versions directly.
38+"Yes / right / sounds good" ≠ permission to act. "How should we do X?" ≠ permission to act. Only an explicit imperative ("build X / change X / start") triggers code.
5839
59−## Sharing Rules
40+> Detailed rules live downstream: must-agree details in §Behavioral parity; component waterfall in §UI components; data / mirroring rules in §Data layer helpers and §Realtime. Pre-flight is the gate; those are the references.
6041
61−Web and desktop share business logic, hooks, stores, components, and views through `packages/core/`, `packages/ui/`, and `packages/views/`.
42+## Behavioral parity with web/desktop
6243
63−If the same logic exists in both web and desktop, extract it unless it depends on platform APIs:
44+Mobile is allowed to differ in **UI and interaction** — it's a phone, not a port. It is NOT allowed to differ in **product semantics**. Users should not get a different mental model of "what's there" depending on which client they open.
6445
65−1. Next.js, Electron, or router APIs stay in the app/platform layer.
66−2. Headless logic belongs in `packages/core/`.
67−3. Shared UI or business views belong in `packages/views/`.
68−4. Shared primitives belong in `packages/ui/`.
46+**The four things that must agree:**
6947
70−Mobile is independent. It may import types and pure functions from `@multica/core`, with `import type` for types, but owns its UI, state, hooks, providers, i18n, React version, build pipeline, and release cadence.
48+- **Counts / visibility** — same N for the same filter, under identical pagination / coalescing rules.
49+- **Permissions / access** — mirror the same logic web uses (from `packages/core`); don't re-derive from feel.
50+- **State enums / transitions** — render every status / priority / inbox type / comment type, with a sensible fallback for unknown values (per "API Response Compatibility" in the root CLAUDE.md). Never silently drop a category.
51+- **Data identity** — same `id`, same `slug`, same canonical fields. Don't invent ids or normalize differently.
7152
72−## Commands
53+**When UI must diverge**, write at the divergence point what rule it's mirroring (point at the source function in `packages/core` or `packages/views`) and why mobile renders it differently. A future reader should be able to tell in 30 seconds that the divergence is intentional and find the web-side source of truth.
7354
74−Use the repo scripts as the source of truth. Common commands:
55+### ⚠️ Incident (2026-05-09): inbox dedup missing — counts disagreed
7556
76−```bash
77−make dev # auto-setup and start the app
78−make start # start backend + frontend
79−make stop # stop app processes for this checkout
80−make server # run Go server only
81−make daemon # run local daemon
82−make test # Go tests
83−make sqlc # regenerate sqlc code after SQL changes
84−pnpm install
85−pnpm dev:web
86−pnpm dev:desktop
87−pnpm build
88−pnpm typecheck
89−pnpm lint
90−pnpm test # TS/Vitest tests through Turborepo
91−pnpm exec playwright test
92−pnpm ui:add badge # shadcn/Base UI component into packages/ui
57+**Symptom**: Web sidebar showed "Inbox 1" while mobile rendered 3+ unread dots on the same workspace, same user, same moment.
58+
59+**Root cause**: Backend `GET /api/inbox` returns raw rows that include:
60+1. archived items, and
61+2. multiple inbox notifications per issue (a comment, a status change, and an assignment on the same issue each create one row).
62+
63+Web/desktop run those raw rows through `deduplicateInboxItems` (`packages/core/inbox/queries.ts`) before rendering and before counting unread:
64+1. filter `archived = true` out
65+2. group by `issue_id`, keep the newest in each group
66+3. sort by `created_at` desc
67+
68+Mobile's first cut rendered the raw list directly. So a single issue with 3 notifications showed as 3 rows with 3 unread dots, while web showed 1.
69+
70+**Fix**: mirror `deduplicateInboxItems` into `apps/mobile/lib/inbox-display.ts`, run mobile's inbox tab through it before rendering and before any counting.
71+
72+**Lesson — encode this into your reflexes when adding any new mobile screen that consumes a list endpoint**:
73+
74+> Before rendering an API list response, grep `packages/core/<domain>/queries.ts` and `packages/views/<domain>/components/*.tsx` for any preprocessing — `dedupe*`, `coalesce*`, `filter*`, `*-display.ts`, `useMemo(() => transform(raw))`. Mirror everything that runs between `useQuery` and the JSX in web/desktop. **Do not assume the backend returns "what should be displayed"** — it usually returns the raw cache shape, and the client is responsible for shaping it.
75+
76+This pattern repeats: timeline coalescing (`buildTimelineGroups`), inbox dedup, comment thread flattening, etc. Each one is a behavioral parity hazard if mobile skips it.
77+
78+## Tech-stack baseline
79+
80+Start minimal. Add to this list when actually adopted — do NOT pre-list libraries.
81+
82+- **Expo SDK 55**
83+- **React Native 0.82**
84+- **React 19.1** — whatever Expo SDK 55 ships. Pinned in `apps/mobile/package.json` directly, NOT via root `catalog:`.
85+- **TypeScript** strict
86+- **Expo Router 55** (file-based routing — version aligns with Expo SDK)
87+- **NativeWind 4** + **Tailwind 3.4** — NativeWind 5 is unstable; stay on v4. (Note: web/desktop use Tailwind v4 — versions intentionally differ.)
88+- **react-native-reusables (RNR)** — the shadcn equivalent for React Native. Uses NativeWind + RN-Primitives + CVA. Component API mirrors shadcn. **Phased adoption in progress — see `apps/mobile/docs/rnr-migration.md` for the canonical plan, three-tier classification, and Phase 0/1/2/3 status.**
89+- **TanStack Query 5** — mobile owns its `QueryClient` with `AppState` focus listener + `NetInfo` online listener.
90+- **Zustand** — mobile-local state only.
91+- **expo-secure-store** — auth token persistence + theme preference (`light` / `dark` / `system`).
92+
93+When upgrading any of these, update this list.
94+
95+## UI components & theming
96+
97+The full plan, file inventory, and migration phases live in `apps/mobile/docs/rnr-migration.md`. The rules below are the durable ones that must survive after the migration completes — read this section first when working on any UI.
98+
99+### Hard rule — existing pattern first, defaults first, native waterfall
100+
101+Three principles govern every UI decision on mobile. They exist to fight the temptation to recreate things that already exist — which is exactly the trap that produced the current 21 hand-written components and 18 hand-rolled sheets.
102+
103+**Principle 1 — existing pattern first.** Before reaching for ANY new component (RNR add, hand-written primitive, new sheet container), grep the mobile codebase for an already-shipped pattern that does the same thing.
104+
105+- Building a row → grep `components/inbox/`, `components/issue/`, `components/project/` for an analogous list-row first.
106+- Building a picker / sheet → check `components/issue/pickers/`, `components/project/pickers/` — there are 8+ pickers; one of them is probably the shape you need.
107+- Building a status / priority / actor visual → `components/ui/status-icon.tsx`, `priority-icon.tsx`, `actor-avatar.tsx` already exist. Re-use, don't re-skin.
108+- Composer / form / detail screen layout → `app/(app)/[workspace]/issue/[id]/`, `chat/`, `new-issue.tsx` — copy the structure, don't reinvent.
109+
110+If a working pattern exists, **import or copy-adapt it**. If it almost-fits but needs a small extension, extend the existing one (one PR) rather than fork a second variant. Only when no existing pattern fits, proceed to Principle 2.
111+
112+Why: every "I'll just write a fresh one" produced one of the 21 legacy components. The codebase already paid the cost of figuring out the iOS-correct shape for inbox rows, picker sheets, status icons — don't re-pay it.
113+
114+**Principle 2 — defaults first.** When you use any RNR component, accept its default variant, default size, default spacing, default palette. Do NOT add wrapper layers, "improved" defaults, or `variant="multicaCustom"` styles unless a concrete product need demands it. Reaching for shadcn defaults is correct; reaching for a hand-tuned version of them is the failure mode.
115+
116+**Principle 3 — iOS native > RNR > discuss.** When you need a new interaction, walk this waterfall in order, stop at the first hit:
117+
118+1. **iOS / RN ships a native API?** Use it directly. Don't wrap a `Modal` to mimic it.
119+ - Text input prompt → `Alert.prompt`
120+ - Confirm / destructive prompt → `Alert.alert`
121+ - Action sheet (one-of-N) → `ActionSheetIOS.showActionSheetWithOptions`
122+ - Date / time → `@react-native-community/datetimepicker` (already installed)
123+ - Image / camera → `expo-image-picker` (already installed)
124+ - Documents → `expo-document-picker` (already installed)
125+ - Share → `Share.share` from `react-native`
126+ - Haptics → `expo-haptics` (already installed)
127+2. **RNR ships a matching component?** `npx @react-native-reusables/cli@latest add <name>`. Use the default variant/size/palette.
128+3. **Neither.** **Stop and ask the user.** Don't silently hand-roll a replacement — that's exactly how the pre-migration legacy accumulated.
129+
130+### Component placement
131+
132+After deciding via the waterfall:
133+
134+- **Generic UI primitives** → `components/ui/`. Either RNR `add` output or hand-written with `cva` + `cn()` + semantic tokens + `@rn-primitives/*` building blocks.
135+- **Domain UI** (anything mentioning issues, priorities, statuses, actors, agents, presence, projects, runs) → `components/<domain>/`. Composes primitives but isn't generic.
136+
137+Never copy the visual shape of an existing hand-written `components/ui/` component as a template if its RNR equivalent exists — most of them are pre-migration legacy. The migration doc tracks which files are legacy and which have been replaced.
138+
139+### Theming model — CSS variables + class-based dark mode
140+
141+- Source of truth for colors is `global.css` — CSS variables defined under `:root` (light) and `.dark:root` (dark). `tailwind.config.js` maps utilities like `bg-background` to `hsl(var(--background))`, so the same class name resolves to the right color in either mode automatically.
142+- `darkMode: 'class'` (NOT media-query). We control the mode explicitly so the in-app Settings → Appearance picker (`light` / `dark` / `system`) can override the OS preference.
143+- The mode is switched by NativeWind's `useColorScheme().setColorScheme(mode)`. Calling it sets the root class; every `bg-foo` / `text-foo` reactively rebinds to the new variable values. No manual className toggling, no re-render dance.
144+- React Navigation (`expo-router`'s `Stack` headers, modal chrome, drawer) is themed separately by passing `NAV_THEME[isDarkColorScheme ? 'dark' : 'light']` into `ThemeProvider`. Source of `NAV_THEME` is `lib/theme.ts`, which mirrors `global.css` in TypeScript.
145+- Persistence: the user's choice goes into `expo-secure-store` under the key `theme-preference` (values: `light` / `dark` / `system`). Loaded synchronously at app startup in `app/_layout.tsx` before the first paint; missing key defaults to `system`.
146+- **When you change a CSS variable in `global.css`, also update `lib/theme.ts`.** They mirror each other. The RNR docs include a prompt template for this sync.
147+
148+### What this replaces (and what stays)
149+
150+- The old "Visual tokens" approach — hand-transcribed hex values in `tailwind.config.js` — is being **replaced** by the CSS-variable system above. Web tokens are still inspiration only; we do NOT import `packages/ui/styles/tokens.css` (Tailwind v3.4 vs v4 mismatch makes file sharing impractical; isolation is intentional).
151+- The `cn()` helper at `lib/utils.ts` stays — RNR uses the same one.
152+- The sheet rule from Lesson 6 below still applies. RNR ships `Dialog` and other modal primitives; use them for **new** sheets. The legacy `sheet-shell.tsx` (RN `<Modal presentationStyle="pageSheet">`) has been deleted — every long-list / search / form sheet now uses an Expo Router `presentation: "formSheet"` route, which instantiates iOS' `UISheetPresentationController` for native grabber, detents, and spring drag physics.
153+
154+## Build & release
155+
156+- **Main CI** (`.github/workflows/ci.yml`) excludes mobile via `--filter='!@multica/mobile'`. Mobile failures do NOT block web/desktop PRs.
157+- **Mobile verify** (`.github/workflows/mobile-verify.yml`): triggered on `apps/mobile/**` or `packages/core/types/**` changes — runs typecheck/lint/test only, no IPA build.
158+- **Mobile release** (`.github/workflows/mobile-release.yml`): triggered by `mobile-v*.*.*` tag → `eas build` + `eas submit`.
159+- **OTA** — EAS Update for JS-only fixes that don't change the runtime version. Manual / on-demand push to preview/production channels.
160+
161+Mobile release cadence is decoupled from main `v*.*.*` tags (server / CLI / desktop).
162+
163+## Realtime / WebSocket strategy
164+
165+Mobile uses the same WS server protocol as web/desktop, but mounts subscriptions differently. The rules below exist because mobile-specific constraints (cellular data cost, AppState lifecycle, per-screen unmount cleanup, smaller cache surface) make a direct port of web's pattern wrong.
166+
167+### Three-layer stack
168+
93169 ```
170+Layer 1 ws-client.ts — single socket, no React. Exponential
171+ backoff with full jitter. Three-state
172+ lifecycle (idle / active / paused) so
173+ the provider can pause on background
174+ and resume on foreground without
175+ racing the auto-reconnect timer.
176+Layer 2 realtime-provider.tsx — owns the WSClient. Mounts/unmounts on
177+ auth + workspace + AppState + NetInfo
178+ changes. Exposes useWSClient().
179+Layer 3 use-<feature>-realtime.ts — per-feature subscriptions. Translate
180+ events → cache mutations.
181+```
94182
95−Worktrees share one PostgreSQL container and get isolated DB names/ports via `.env.worktree`. `make dev` auto-detects this. For manual setup use `make worktree-env`, `make setup-worktree`, and `make start-worktree`. `pnpm dev:desktop` additionally self-isolates per worktree (its own renderer port + app name) automatically, independent of `.env.worktree`.
183+Layer 3 is what changes per feature; layers 1 and 2 are infrastructure and shouldn't be edited when adding event coverage.
96184
97−CI runs Node 22, Go 1.26.1, and a `pgvector/pgvector:pg17` PostgreSQL service.
185+### Mount strategy: list-level global, per-record per-screen
98186
99−## Database and Migration Rules
187+Mobile **does NOT use a single centralized `useRealtimeSync` hook** like `packages/core/realtime/use-realtime-sync.ts`. That pattern is fine on web (one tab = one mount, lives forever) but on mobile it gets in the way: most events care about a single record (one issue's comments, one chat session's messages), and the hook needs to know which record without prop-drilling.
100188
101−These are hard requirements for every new or modified database design and production migration:
189+Two mount tiers:
102190
103−- Do not add database foreign keys (`FOREIGN KEY` / `REFERENCES`), cascading deletes, or cascading updates. Resolve relationships, validation, and dependent cleanup explicitly in application code. Use an application transaction when cleanup and the parent operation must commit or roll back atomically.
104−- Every index created by a migration must use `CREATE INDEX CONCURRENTLY` or `CREATE UNIQUE INDEX CONCURRENTLY`, including indexes on newly created tables. PostgreSQL rejects concurrent index creation inside a transaction or a multi-command string, so keep each concurrent index build in its own single-statement migration file. The repository migration runner executes migration files outside an explicit transaction to support this.
191+- **Listing-level (always-on for the workspace session)** — mount inside the `<RealtimeSubscriptions />` component in `app/(app)/[workspace]/_layout.tsx`. These don't take parameters; they patch caches keyed only on `wsId`. Examples: `useInboxRealtime`, `useMyIssuesRealtime`. Both run from the moment the user enters a workspace until they leave it, regardless of which tab is foregrounded.
105192
106−## Coding Rules
193+- **Per-record (mounted with id, cleans up on unmount)** — mount inside the screen that owns the record, parameterized by the id from the route. Example: `useIssueRealtime(id, () => router.back())` in `issue/[id].tsx`. The hook filters every event by `payload.issue_id === id` and only patches the current issue's caches. When the user navigates away the `useEffect` cleanup unsubscribes all listeners, so a backgrounded screen doesn't keep mutating caches it no longer owns.
107194
108−- TypeScript strict mode is enabled; keep types explicit.
109−- Go follows standard conventions: `gofmt`, `go vet`, checked errors.
110−- Code comments must be English.
111−- Prefer existing patterns/components over new parallel abstractions.
112−- Avoid broad refactors unless required by the task.
113−- For internal, non-boundary code, do not add compatibility layers, fallback paths, dual writes, legacy adapters, or temporary shims unless explicitly requested.
114−- API boundaries are different: installed desktop clients can talk to newer backends, so response parsing must follow the API compatibility rules below.
115−- If a flow or API is being replaced and the product is not live, prefer removing the old path instead of preserving both.
116−- New global pre-workspace routes must be a single word (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Do not add hyphenated root routes like `/new-workspace`.
117−- Reserved slugs live in `server/internal/handler/reserved_slugs.json`. Edit it, run `pnpm generate:reserved-slugs`, and commit the generated `packages/core/paths/reserved-slugs.ts`.
118−- When changing CLI commands/flags, API fields, or product behavior documented by built-in skills under `server/internal/service/builtin_skills/*`, update the relevant `SKILL.md` and `references/*-source-map.md` in the same PR.
195+Don't mount a per-record hook globally to "just be safe" — every filter call on every event then runs N times where N is the number of issues a user has ever opened in this session.
119196
120−## API Compatibility
197+### Patch over invalidate (cellular-data rule)
121198
122−Frontend code must survive backend response drift, especially in installed desktop builds.
199+When a WS payload contains the full updated object, **patch** the cache (`setQueryData` / `setQueriesData`). Only fall back to **invalidate** when:
123200
124−- Parse API JSON with `parseWithFallback` in `packages/core/api/schema.ts` and a zod schema. Do not cast network JSON to `T`.
125−- Endpoint responses consumed by UI logic must pass through a schema before returning.
126−- Downstream UI should optional-chain and default fields defensively.
127−- Prefer explicit boolean checks (`=== true`) over truthy/falsy checks on server fields.
128−- Do not pin critical affordances to one backend boolean; combine signals when possible.
129−- Server-driven enum switches need a `default` branch.
130−- When adding or changing an endpoint, add/update the schema and include a malformed-response test.
201+1. The payload is just an id (we don't know the full new shape — e.g., `issue:created` with no scope context).
202+2. The cache shape doesn't match what we can patch (e.g., multi-key scope-filtered lists where we'd have to predict membership).
203+3. The event is rare enough that the extra refetch isn't a real cost (e.g., `issue:deleted` on a list that was about to invalidate anyway).
204+4. After a reconnect, where we may have missed events while disconnected.
131205
132−## Backend UUID Rules
206+Web is fine to invalidate generously because most users are on broadband; mobile users on cellular pay for each refetch. A `setQueryData` is free; an `invalidateQueries` is a network roundtrip per affected query key.
133207
134−In `server/internal/handler/`, always know where a UUID came from before using it in write queries.
208+### Mobile-owned updaters (don't import `packages/core/issues/ws-updaters.ts`)
135209
136−- Resource path params that may be UUIDs or human-readable IDs must be resolved through loaders such as `loadIssueForUser`, `loadSkillForUser`, `loadAgentForUser`, or `requireDaemonRuntimeAccess`; subsequent writes use the resolved `entity.ID`.
137−- Pure UUID inputs from request boundaries use `parseUUIDOrBadRequest(w, s, fieldName)` and return immediately on `ok=false`.
138−- Trusted UUID round-trips from sqlc results or test fixtures use `parseUUID(s)`, which panics on invalid input.
139−- Outside handlers, `util.ParseUUID(s) (pgtype.UUID, error)` is the safe variant; always check the error.
210+Mobile has its own `apps/mobile/data/realtime/issue-ws-updaters.ts` even though web has a near-identical file. **Do not import web's updaters into mobile.** Two reasons:
140211
141−## Web/Desktop Features
212+1. **Key-factory binding.** Web's updaters reference `issueKeys` from `packages/core/issues/queries.ts` — a different runtime instance from mobile's `apps/mobile/data/queries/issue-keys.ts`. TanStack Query compares keys structurally so it *appears* to work, but binding cache mutation to a foreign key factory invites silent drift the moment either side adjusts its key shape (renames a segment, adds a discriminator).
213+2. **Cache-shape divergence.** Mobile has simpler caches: flat `Issue[]` for my-issues (web has status-bucketed); no children subtree (web does); no label-byIssue cache (web does). Web's updaters carry conditional dead-code for paths mobile doesn't have, and mobile would silently no-op on web shapes that don't exist locally.
142214
143−When adding a shared page or feature for web and desktop:
215+When the same logic needs to exist on both sides, copy the design — not the import. Document the mirror at the top of the mobile file (see `issue-ws-updaters.ts` for the pattern).
144216
145−1. Put the page/component in `packages/views/<domain>/`.
146−2. Add platform wiring in both `apps/web/app/` and the desktop router, unless the desktop flow is a transition overlay.
147−3. Use `useNavigation().push()` or `<AppLink>` in shared code.
148−4. Use shared guards/providers such as `DashboardGuard` from `packages/views/layout/`.
149−5. Keep platform-only UI in the app or inject it through props/slots.
150−6. Hooks that need workspace context should accept `wsId`.
217+### Event-always-wins (optimistic conflict policy)
151218
152−CSS for web/desktop is shared from `packages/ui/styles/`. Use semantic tokens such as `bg-background` and `text-muted-foreground`; avoid hardcoded Tailwind colors and duplicated base styles.
219+Mutations like `useUpdateIssue` apply an optimistic patch to the detail cache, then the server processes the request and broadcasts `issue:updated`. If a separate WS event (from another client / another user / an agent) arrives between the optimistic patch and the mutation response, the WS handler overwrites the optimistic state with the server's authoritative state. Brief UI flicker is acceptable; correctness wins.
153220
154−## Desktop Rules
221+**Do not** add timestamp-comparison logic to "protect" the optimistic state — the server is the truth and the user benefits from seeing real changes immediately. If a specific event proves problematic in practice, add the gate at that point, not by default.
155222
156−Desktop routing has three categories:
223+### Reconnect handling
157224
158−- Session routes: workspace-scoped tab destinations such as `/:slug/issues`.
159−- Transition flows: pre-workspace one-shot actions such as create workspace or accept invite. These are `WindowOverlay` state, not routes.
160−- Error/stale states: stale workspace tabs should auto-heal by dropping stale tab groups, not render desktop error pages.
225+Each hook registers a single `ws.onReconnect(cb)` that invalidates **only the queries it owns**:
161226
162−More desktop constraints:
227+| Hook | Invalidates on reconnect |
228+|---|---|
229+| `useInboxRealtime` | `inboxKeys.list(wsId)` |
230+| `useMyIssuesRealtime` | `issueKeys.myAll(wsId)` |
231+| `useIssueRealtime(id)` | `issueKeys.detail(wsId, id)` + `issueKeys.timeline(wsId, id)` |
163232
164−- New pre-workspace desktop flows register a `WindowOverlay` type in `stores/window-overlay-store.ts`; do not add them to `routes.tsx`.
165−- `setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` mirrors the active route for headers, storage namespaces, and reconnects; workspace route layouts own setting it.
166−- Code that leaves workspace context must call `setCurrentWorkspace(null, null)` explicitly.
167−- Workspace delete must await the server before navigation/cleanup. Workspace leave currently clears/navigates before mutation only to avoid the `member:removed` realtime race; treat that as known debt, not a reusable pattern.
168−- Cross-workspace navigation must go through the navigation adapter so it can call `switchWorkspace(slug, targetPath)`.
169−- Full-window desktop views outside the dashboard shell must mount `<DragStrip />` from `@multica/views/platform` as the first flex child. Interactive controls in the top 48px need `WebkitAppRegion: "no-drag"`.
233+No global "invalidate everything on reconnect" sweep. The fanout would be every screen the user has ever visited in this session refetching simultaneously — wasteful on cellular and prone to rate-limiting the server in low-signal areas where reconnects happen frequently.
170234
171−## Mobile Rules
235+### Cross-cutting cache patches across features
172236
173−Read `apps/mobile/CLAUDE.md` before touching `apps/mobile/`. It contains the mandatory pre-flight process, import limits, parity rules, tech stack, UI rules, data helpers, realtime strategy, and mobile release flow.
237+Some events legitimately need to mutate a foreign feature's cache. The
238+canonical example: `issue:updated` changing an issue's status must also
239+update the StatusIcon shown on the matching inbox row, and `issue:deleted`
240+must strip every inbox row pointing at the dead issue.
174241
175−Root-level reminders:
242+The pattern:
176243
177−- Mobile shares only `@multica/core` types and pure functions.
178−- Mobile must match web/desktop product semantics: counts, permissions, enums/transitions, and data identity.
179−- Mobile may differ in UI/interaction when the phone context requires it.
244+1. **The feature whose cache is being patched owns the updater.** Example:
245+ `apps/mobile/data/realtime/inbox-ws-updaters.ts` exports
246+ `patchInboxIssueStatus` and `dropInboxItemsByIssue` — they live with
247+ inbox, not with issues, because they read `inboxKeys.list(wsId)`.
248+2. **That feature's realtime hook subscribes to the foreign event.**
249+ `use-inbox-realtime.ts` subscribes to `issue:updated` and `issue:deleted`
250+ alongside the `inbox:*` events. The issue-realtime hook does NOT know
251+ that inbox cares.
252+3. **Mirror web's wiring.** Web's `packages/core/inbox/ws-updaters.ts` has
253+ the same handlers; mobile copies the design. Behavioral parity hazard:
254+ without these the mobile inbox row keeps showing the prior status (or
255+ 404s on tap if the issue is gone) while web users see the change live.
180256
181−## UI Rules
257+If you find yourself reaching across features in `use-issues-realtime` to
258+patch something else, you have the inversion: move the updater to the
259+patched feature and subscribe there.
182260
183−- Prefer shadcn/Base UI components over custom implementations. Add them with `pnpm ui:add <component>` from the repo root.
184−- The Pro `@reui` registry is configured in `packages/ui/components.json`; add items with `pnpm ui:add @reui/<name>` and answer `n` to every overwrite prompt so local component customizations survive. It reads `REUI_LICENSE_KEY` from the environment — agents get it from their Multica agent environment, humans export it in their own shell. Never write the key into a repo file.
185−- ReUI ships source, not a dependency: route the vendored output to our layout (new primitives to `packages/ui/components/ui/`, compositions to `packages/views/<domain>/`) and rewrite it to our conventions before committing.
186−- Use design tokens and semantic classes; avoid hardcoded colors.
187−- Do not introduce extra local state unless the design requires it.
188−- Handle overflow, long text, scrolling, alignment, and spacing deliberately.
189−- If a component is identical between web and desktop, it belongs in a shared package.
261+### Adding new event coverage — recipe
190262
191−## Testing
263+1. **Read the payload.** Find the event in `@multica/core/types/events.ts`. Note the fields; decide if patch is possible (full object) or invalidate is required (just an id).
264+2. **Mirror, don't import.** If web has an updater for this event in `packages/core/<feature>/ws-updaters.ts`, copy the design into `apps/mobile/data/realtime/<feature>-ws-updaters.ts`. Adapt to mobile's actual cache shapes — don't carry web's bucket/children/childProgress dead-code if mobile doesn't have those caches.
265+3. **Subscribe in a hook.** Either extend an existing `use-<feature>-realtime.ts` or create a new one. Filter by id at the top of each handler so per-record hooks ignore unrelated events.
266+4. **Mount it.** Listing-level → add to `<RealtimeSubscriptions />` in workspace `_layout.tsx`. Per-record → add to the owning screen's body, parameterized by the route id.
267+5. **Add reconnect invalidate.** Single `ws.onReconnect()` call scoped to the hook's own keys.
268+6. **Verify cross-client.** Open the affected screen on mobile, change the same record from a second client (web or another device), confirm mobile updates within ~500ms without pull-to-refresh.
192269
193−Tests follow the code:
270+If a new event has no consumer on mobile (e.g., `subscriber:added` when mobile doesn't render subscriber lists yet), **don't subscribe**. Mounting a listener with no UI consumer adds CPU on every fire for zero user benefit.
194271
195−| What is tested | Location |
196−| --- | --- |
197−| Shared business logic, stores, queries, hooks | `packages/core/*.test.ts` |
198−| Shared UI components, pages, forms, modals | `packages/views/*.test.tsx` |
199−| Platform wiring such as cookies, redirects, search params | `apps/web/*.test.tsx` or `apps/desktop/` |
200−| End-to-end flows | `e2e/*.spec.ts` |
201−| Backend | `server/` Go tests |
272+## Data layer helpers (use these — don't recreate them)
202273
274+Common boilerplate is wrapped. New code that reinvents these helpers is a
275+review-block, both because it makes the codebase inconsistent AND because
276+the helpers encode subtle correctness rules (signal forwarding, schema
277+fallback, sync-before-await ordering, type-safe payloads).
278+
279+### Three rails that every feature must follow
280+
281+1. **Logic mirrors web/desktop.** See §Pre-flight step 1 at the top of
282+ this file. Restating the data-contract half here: endpoints, request
283+ bodies, response schemas, optimistic patches, and cache key prefixes
284+ all match web verbatim. UI / interaction can diverge freely per
285+ §Behavioral parity.
286+
287+2. **Use the existing components — no new primitives.** Walk the
288+ `iOS native > RNR > discuss` waterfall in §UI components. If RNR ships
289+ it, `npx @react-native-reusables/cli@latest add <name>`. If iOS ships
290+ it (Alert / ActionSheetIOS / Haptics / share / picker), use it directly.
291+ If neither has it AND it's a single-screen need, inline compose with
292+ `<Pressable>` + `<Text>` + tokens. **Do NOT create a new generic
293+ primitive in `components/ui/` for one or two callers** — the migration
294+ doc lists "21 hand-written components" as exactly the trap we're
295+ escaping. Threshold for a new primitive is three callers AND no
296+ RNR/iOS-native alternative.
297+
298+3. **Use the wrapped request / WS layer.** See the helper map below.
299+
300+### API client: `fetchValidated` + `fetchValidatedWith`
301+
302+`apps/mobile/data/api.ts` exposes two private helpers on `ApiClient` that
303+collapse the fetch + parseWithFallback envelope. **Every new read-side
304+method that returns a typed body must use them.**
305+
306+| Helper | When to use | Shape |
307+|---|---|---|
308+| `this.fetchValidated(path, schema, fallback, opts?)` | GET endpoints | One-liner method body — see `getMe`, `listInbox`, `getNotificationPreferences` |
309+| `this.fetchValidatedWith(path, schema, fallback, init, opts?)` | Any HTTP method (PATCH / PUT / POST) whose response is consumed | Carries the body via `init.body` + method; signal forwarding handled |
310+| `this.fetch<T>(path, init?)` directly | Writes whose response is `{ count }` / `void` / not consumed by UI logic | Only here is a raw `as T` acceptable, because the value never reaches a render path |
311+
203312 Rules:
313+- The fallback object MUST match the success type exactly so downstream
314+ code never has a partial value (see `EMPTY_USER` / `EMPTY_INBOX_LIST`
315+ pattern in `apps/mobile/data/schemas.ts`).
316+- The `endpoint` label is for telemetry — defaults to the path; override
317+ only when the path has dynamic segments and you want stable groupings
318+ (`GET /api/issues/:id` not `GET /api/issues/abc-123`).
319+- Migration is progressive: not every legacy method is converted yet.
320+ Adding a new method? Use the helpers. Touching an old method that
321+ isn't using them? Convert it as part of the same PR.
204322
205−- Never test shared component behavior in an app test file.
206−- `packages/views/` tests must not mock `next/*` or `react-router-dom`.
207−- Mock `@multica/core` stores with the Zustand callable-store shape (`selectorFn` plus `getState`).
208−- Mock `@multica/core/api` for API calls.
209−- E2E tests should use `TestApiClient` for setup/teardown.
210−- Prefer writing the failing test in the correct package before implementation when the change is behavioral.
211−- Default tests must never resolve or execute user-installed agent CLIs. Pass a test-created fake executable path or a test-created missing path to agent subprocess code.
212−- Real-agent smoke tests belong behind the `agentintegration` build tag and must check `MULTICA_RUN_REAL_AGENT_SMOKE=1` before executable lookup or account access.
213−- Run an explicitly authorized real-agent smoke test with `(cd server && MULTICA_RUN_REAL_AGENT_SMOKE=1 go test -tags=agentintegration ./pkg/agent -run '<test-name>' -count=1 -v)`. This command may access an authenticated account and consume quota.
214−- When adding a default agent command, add it to `scripts/agent-cli-command-names.txt`; the normal Linux/macOS test entry points fail on ambient agent CLI execution.
323+### Query / mutation factory pattern
215324
216−## Verification
325+Every workspace-scoped feature exposes a key factory in
326+`apps/mobile/data/queries/<feature>.ts`:
217327
218−For code changes, run the narrowest useful checks while iterating, then run broader verification when risk justifies it or when asked.
328+```ts
329+export const inboxKeys = {
330+ all: (wsId: string | null) => ["inbox", wsId] as const,
331+ list: (wsId: string | null) => [...inboxKeys.all(wsId), "list"] as const,
332+};
333+```
219334
220−Useful checks:
335+Three-segment shape matches web (`packages/core/inbox/queries.ts`).
336+Reasons:
221337
222−```bash
223−pnpm typecheck
224−pnpm test
225−make test
226−pnpm exec playwright test
227−make check
338+- TQ does prefix matching by default — `invalidateQueries({ queryKey:
339+ inboxKeys.all(wsId) })` invalidates the list AND any future sub-keys
340+ (e.g. a `detail(id)`) under the same prefix. Use `.all` to clear a
341+ workspace cleanly, `.list` to target the list specifically.
342+- Cross-platform mental-model parity: a reader switching between mobile
343+ and web finds the same key shape.
344+- Stops bare `["inbox", wsId]` strings from spreading. Grep
345+ `\["inbox"` in this codebase should only hit the factory file.
346+
347+Mutations import the factory and use `inboxKeys.list(wsId)` everywhere —
348+never inline strings.
349+
350+### WS layer: `ws.on<E>()` + `useWSSubscriptions`
351+
352+Two helpers replace ~20 lines of boilerplate per realtime hook:
353+
354+1. **`ws.on<E extends WSEventType>(event, handler)`** — the handler's
355+ `payload` parameter is auto-typed to `WSEventPayload<E>`. **Do not
356+ add `as XxxPayload` casts at handler bodies** — they're redundant
357+ and (worse) silently hide drift if `WSEventPayloadMap` shifts.
358+ The cast is only acceptable when one handler covers multiple events
359+ that don't share a typed common ancestor (see `onTaskEvent` in
360+ `use-issue-realtime.ts` — `task:progress` has no formal payload).
361+2. **`useWSSubscriptions(setup, deps)`** in
362+ `apps/mobile/lib/use-ws-subscriptions.ts` — wraps the
363+ `if (!ws || !wsId) return; useEffect + cleanup` template. Setup
364+ callback receives `(ws, wsId)`, returns the unsub array (or
365+ `undefined` to short-circuit, e.g. when a per-record id is missing).
366+
367+Adding a new event type? Extend `packages/core/types/events.ts`:
368+
369+1. Add the event to the `WSEventType` union.
370+2. Add the payload interface.
371+3. Add the `WSEventType → payload` entry in `WSEventPayloadMap`.
372+
373+Forgetting step 3 means callers get `unknown` (loud — they have to
374+narrow), not `any` (silent unsafe access). That's the safety net.
375+
376+### Synchronous setQueryData before `await cancelQueries`
377+
378+Optimistic mutations that flip state read by a UI element that's about
379+to be in a navigation snapshot (the classic case: marking an inbox row
380+read, then `router.push` to the issue) MUST call `setQueryData` in
381+`onMutate` **before** `await qc.cancelQueries(...)`. The await yields
382+one microtask; iOS captures the source-view snapshot during that gap and
383+freezes the row in its unread style inside the slide-in transition.
384+
385+Lives inside the mutation, not the caller. See `useMarkInboxRead.onMutate`
386+in `apps/mobile/data/mutations/inbox.ts` for the canonical example.
387+
388+### Checklist for a new feature
389+
390+Before opening a PR for a new screen / mutation / realtime hook:
391+
392+1. Grep `packages/core/<feature>/` for the web equivalent — endpoints,
393+ key shape, optimistic patch shape. Mirror, don't invent.
394+2. API methods → `fetchValidated` / `fetchValidatedWith` (or raw
395+ `this.fetch` only for writes with no consumed response).
396+3. Query key → factory in `data/queries/<feature>.ts`, 3-segment shape.
397+4. Mutations → optimistic only when the post-state is locally predictable,
398+ the user stays on the same screen, failure is rare, and rollback is
399+ trivial. When that gate passes, use snapshot → patch → rollback +
400+ settle invalidate, all keys via factory. Create/delete/navigate/confirm
401+ flows await the server instead.
402+5. Realtime → `useWSSubscriptions(setup, deps)`, typed `ws.on<E>()`,
403+ per-event patching (no global invalidate) when payload carries the
404+ full object.
405+6. UI → waterfall (iOS native > RNR > inline compose). No new
406+ `components/ui/` primitive unless three callers + RNR doesn't ship.
407+7. Verify cross-client: change the same record from web and confirm
408+ mobile updates within ~500ms without pull-to-refresh.
409+
410+## Lessons learned (encode into reflexes)
411+
412+These are real mistakes that have been made building the mobile shell. Each one cost time to find. Treat as enforceable rules, not suggestions.
413+
414+### 1. Install/upgrade any dependency: check `dist-tags` first
415+
416+Do NOT hardcode version numbers from memory. Run `pnpm view <pkg> dist-tags` to see `latest / sdk-XX / canary` and decide which tag to lock. For Expo packages (`expo-*` / `react-native-*` that Expo aligns), use `pnpm exec expo install <pkg>` — it queries Expo's dependency manifest and picks the SDK-compatible version. `pnpm add <pkg>` will silently install the npm `latest`, which often outpaces the SDK and breaks at runtime. Past mistakes: hardcoded `expo@~54.0.0` (latest was already `55.x`); installed `lucide-react-native@0.468` without checking React 19 peer compatibility.
417+
418+### 2. New source subdirectory: verify git tracking
419+
420+Every time you create a new source subdirectory under `apps/mobile/` (e.g. `data/`, `lib/foo/`, `components/inbox/`):
421+
422+1. Run `git check-ignore -v <dir>/<file>` immediately. The repo-root `.gitignore` has generic rules (`data/`, `build/`, `bin/`, `*.app`, `*.dmg`) that are intended for backend runtime/output dirs but will silently swallow mobile source.
423+2. If a rule matches, add `!<dir>/` and `!<dir>/**` to `apps/mobile/.gitignore` (subtree override beats parent rule).
424+3. After the commit lands, run `git ls-files <dir>` to confirm every file is tracked.
425+
426+This rule exists because `apps/mobile/data/` was once committed-but-not-tracked — 14 source files (ApiClient, all queries, all stores) were missing from the git tree even though `git status` was clean. Local builds worked because Metro reads the filesystem; CI / clones would have died.
427+
428+### 3. ApiClient capability list (4 must-haves)
429+
430+Mobile's fetch wrapper (`apps/mobile/data/api.ts`) MUST implement all four. Missing any of them is a bug, not a deferred polish item.
431+
432+1. **Zod `parseWithFallback` for response validation.** Strictly enforced by the root CLAUDE.md "API Response Compatibility" section and the "Type drift defense" section above. **Any new endpoint method that does `as T` on the response body is a bug.** Reuse schemas from `packages/core/api/schemas.ts` (pure Zod exports, on the mobile sharing whitelist); define mobile-side fallbacks for new endpoints in `apps/mobile/data/`.
433+
434+2. **`onUnauthorized` 401 callback.** The `ApiClientOptions.onUnauthorized` hook fires on every 401 and must be wired in `app/_layout.tsx` to: clear auth token, clear workspace store, clear TanStack Query cache, navigate to `/login`. Without it a session that expired server-side puts every subsequent request into a 401 loop and the user sees opaque "API error: 401" toasts on every screen. Use a `signingOutRef` to make the callback idempotent — multiple in-flight requests will all 401 simultaneously when a session expires.
435+
436+3. **`X-Request-ID` per request.** Generate a short random ID (`createRequestId()` in `apps/mobile/lib/request-id.ts`), send as `X-Request-ID` header. The same ID goes into client-side log lines so backend telemetry can be cross-referenced (server picks it up via the same header).
437+
438+4. **Structured request logger.** Two log lines per request: `[api] → METHOD path` (start, with `rid`) and `[api] ← STATUS path` (end, with `rid` + `duration`). Use `console.error` for 5xx, `console.warn` for 404s, `console.log` for success. Without this, debugging mobile API issues means staring at the React Native Network panel; with it, the dev console is self-explanatory and prod telemetry already comes structured.
439+
440+**What mobile correctly does NOT need (don't add these):** CSRF token (`X-CSRF-Token`), `credentials: "include"`, cookie reading. Mobile is Bearer-token auth, not cookie auth — the cookie attack surface that requires CSRF protection on web doesn't exist on mobile.
441+
442+### 4. Every read query must pass `signal` to fetch; api.ts always has a hard timeout
443+
444+**Symptom that triggered the rule (2026-05-11)**: Inbox screen sometimes returned to the foreground showing the FlatList pull-to-refresh spinner stuck indefinitely. List items were rendered underneath, but `isRefetching` never flipped back to `false`. Pull-to-refresh, navigating away, and re-opening the tab did not clear it.
445+
446+**Root cause**: `apps/mobile/data/api.ts`'s `fetch()` had no timeout, no `AbortController`, and no caller-`signal` plumbing. iOS suspends backgrounded apps within ~30 seconds and can silently kill in-flight network tasks (facebook/react-native#35384 — "iOS fetch() POST fails if called too soon, with app running in background"; facebook/react-native#38711 — "JS Timers don't fire when app is launched in background"). When the app foregrounded, the suspended fetch's Promise neither resolved nor rejected. TanStack Query saw an existing query still in `fetching` state and did NOT start a new fetch on invalidate — it just waited on the dead Promise forever. `isRefetching` stayed `true`, the FlatList spinner stayed spinning.
447+
448+**Rule, three parts (every one is required — partial fixes leave a footgun)**:
449+
450+**1. `api.ts` `fetch()` MUST have a hard timeout** (currently 30s; the `FETCH_TIMEOUT_MS` constant). Without this, a single suspended request can wedge a query indefinitely. Use a manual `AbortController` + `setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)` — **DO NOT** use `AbortSignal.timeout()`: Hermes throws `TypeError: AbortSignal.timeout is not a function` (facebook/react-native#42042). Same for `AbortSignal.any()` — Hermes does not implement it (livekit/livekit#4014). To combine the timeout signal with a caller-supplied signal, attach an `"abort"` event listener manually and forward to the inner controller.
451+
452+**2. Every read-side `api.ts` method MUST accept `opts?: { signal?: AbortSignal }` and pass it to `fetch()`**. Mutations don't need this (TanStack Query doesn't pass a signal to `mutationFn`). The pattern:
453+```ts
454+async listInbox(opts?: { signal?: AbortSignal }): Promise<InboxItem[]> {
455+ return this.fetch<InboxItem[]>("/api/inbox", { signal: opts?.signal });
456+}
228457 ```
458+Adding a new query-bound method without `opts` is a bug — the next person who writes a `queryFn` will silently drop the signal.
229459
230−Do not claim verification passed unless you ran it. If you skip checks because the change is docs-only or the user asked not to run them, say so.
460+**3. Every `queryFn` MUST forward the signal it receives from TanStack Query**. The official TanStack guide (tanstack.com/query/v5/docs/framework/react/guides/query-cancellation) states: "When a query becomes out-of-date or inactive, this `signal` will become aborted." The pattern:
461+```ts
462+queryOptions({
463+ queryKey: [...],
464+ queryFn: ({ signal }) => api.listInbox({ signal }),
465+});
466+```
467+Forgetting the destructure (writing `() => api.listInbox()`) defeats every benefit of (1) and (2): TQ can't cancel hung requests when the user navigates away, and on workspace switch every stale request lives until its 30s timeout.
231468
232−## Commits and Releases
469+**Verification**: After any change to `api.ts` or a new query addition, `grep -n "queryFn: () =>" apps/mobile/data/queries/` should return zero matches. Every `queryFn` should destructure `{ signal }`.
233470
234−- Commits should be atomic and use conventional prefixes: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
235−- A production deployment requires a CLI release tag on `main`: create `v0.x.x`, push it, and let `release.yml` publish binaries and the Homebrew tap.
236−- Bump patch by default unless the user specifies a version.
471+**Why the wiring already in `data/query-client.ts` (focusManager + AppState, onlineManager + NetInfo) is not enough on its own**: focusManager triggers a *refetch attempt* when the app comes back to the foreground, but if the prior fetch promise is hanging, TQ won't start a new request — it'll keep waiting on the dead one. Only timeout + signal cancellation actually unwedges the query. The three pieces work together: signal lets TQ proactively cancel on staleness, timeout is the safety net when nothing else fires, focusManager is the "user came back, let's recheck" trigger.
237472
238−## Domain Reminders
473+### 5. Modal container selection: match container to content, don't copy the first sheet
239474
240−- All queries filter by `workspace_id`; membership gates access; `X-Workspace-ID` selects the workspace.
241−- Issue assignees are polymorphic: `assignee_type` plus `assignee_id` can reference a member or an agent.
475+The mobile codebase started with ~15 Modal sheets. They almost all copied the same shape (`Modal transparent fade` + hand-drawn `bg-black/40` backdrop + centered/bottom card with `maxHeight`). That shape is correct for **short action menus** (the earliest sheets), wrong for **everything else**. Once the pattern was established as "the mobile sheet style," subsequent sheets inherited it regardless of content — and inherited a different bug each time: keyboard squashing the card, `maxHeight: 380` clipping FlatLists on tall phones, `useSafeAreaInsets` returning 0 inside Modal so bottom content collides with the Home Indicator, etc.
476+
477+**Choose the container by content type, not by "what the last sheet did":**
478+
479+| Content shape | Container | Why |
480+|---|---|---|
481+| < 5 fixed actions, 1-2s stay, no keyboard | `Modal transparent` + bottom action card | Short, light, dim-backdrop tap-to-dismiss is correct here |
482+| Yes/No or one-tap confirm | `Alert.alert` | Native, accessible, no custom UI |
483+| One-of-N from a server-driven short list | `ActionSheetIOS.showActionSheetWithOptions` | Native iOS action sheet, no custom UI |
484+| < 7 fixed picker options, no search | `Modal transparent` + small centered card | Same as action card, just centered |
485+| Long list / search box / content view / form / anything with a keyboard | **Expo Router `presentation: "formSheet"` route** | Instantiates iOS `UISheetPresentationController`: native grabber, drag-dismiss with spring physics, stacked-card backdrop, detents — all UIKit-managed |
486+| Multi-screen flow / route-level full modal | Expo Router `presentation: "modal"` | Full-page slide-up, has back-stack, swipe-dismiss, deep-linkable |
487+
488+**`SheetShell` is deleted.** It was a wrapper around RN core `<Modal presentationStyle="pageSheet">` which does NOT instantiate `UISheetPresentationController` — so it never had native grabber, stacked-card backdrop, or real spring physics. Every former SheetShell call site is now an Expo Router formSheet route.
489+
490+**Rules for adding a new formSheet route:**
491+
492+1. **File goes under the parent context** so the URL reads sensibly — issue-detail pickers under `app/(app)/[workspace]/issue/[id]/picker/<field>.tsx`; project pickers under `project/[id]/picker/<field>.tsx`; transient action sheets under `<context>/<noun>/actions.tsx`. The new-issue draft flow has its own `new-issue-picker/<field>.tsx` directory because routes can't share state with the modal that opened them — see the draft-store discussion below.
493+2. **Register the Stack.Screen in `app/(app)/[workspace]/_layout.tsx`** using the shared `SHEET_OPTIONS` constant. Do NOT inline the config per screen — every picker-row sheet must look and feel identical (grabber, detents, corner radius). Isolated sheets that have no neighbour to be consistent with may override `sheetAllowedDetents` only (e.g. the `menu` sheet uses `"fitToContents"` because it's ≤ 5 fixed actions and the two-snap default would leave 60% blank).
494+3. **Self-contained route bodies.** A picker route reads the record it needs from the TanStack Query cache (issue / project / timeline are already cached when the user gets there), calls its own mutation on submit, and `router.back()`s. No callbacks back up to a parent. The only legitimate exception is the new-issue draft flow, which uses `useNewIssueDraftStore` because the issue doesn't exist yet — there's nothing in cache to read.
495+4. **Header is drawn inside the body**, not by the Stack. SHEET_OPTIONS sets `headerShown: false`; the body renders its own `<View>` with title + optional right action. The native Stack header on a formSheet creates a layout dance with the grabber that doesn't match iOS sheets.
496+
497+**SHEET_OPTIONS rationale (every value exists for a known bug or platform behavior):**
498+
499+- `presentation: "formSheet"` — the magic that hands the screen to `UISheetPresentationController`.
500+- `sheetGrabberVisible: true` — the iOS native drag handle. Users don't discover the gesture without it.
501+- `sheetAllowedDetents: [0.6, 0.95]` — explicit numeric detents. The ergonomic `"fitToContents"` is broken on iOS 26 + Expo 55 (expo/expo#42904 padding inconsistency, #42965 zero-size). Predictable two-snap presentation across every picker-row sheet is more important than shrink-wrapping; every formSheet that lives in a chip row (issue-detail / project-detail AttributeRow) uses these explicit detents so muscle memory carries across the row. Isolated sheets (no chip-row neighbour) override with `"fitToContents"` — see the workspace `menu` sheet for the canonical example.
502+- `sheetCornerRadius: 20` — matches RNR card radius. Without this iOS uses a larger system default that's slightly out of sync with the rest of the app.
503+- `contentStyle: { height: "100%" }` — safety net against the zero-size class of bugs above. Ensures the sheet body fills the allotted detent height.
504+
505+**Caveats that still apply:**
506+
507+- **Android falls back to a regular modal** — no rounded corners, no native drag. mobile/CLAUDE.md treats iOS as the primary target so this is acceptable, but document inline at the call site if a particular feature must work identically on both.
508+- **A formSheet pushed from inside a `presentation: "modal"` route is supported** by Expo Router 55 / RN Screens 4, but the back gesture from the formSheet returns to the modal, not the underlying tab. This is the right UX for the new-issue draft flow (sheet dismisses back to the form), but check the navigation graph if you're adding a sheet under a non-obvious parent.
509+
510+**Carve-out — picker-row consistency wins over per-container optimisation:**
511+
512+The table above says "< 7 fixed picker options → centered card". That rule
513+applies in isolation, but **breaks down when multiple pickers coexist in
514+the same chip row** (issue-detail AttributeRow is the canonical case:
515+status / priority / assignee / label / project / due-date all sit next
516+to each other). Mixing centered cards (for status/priority, short
517+fixed lists) with formSheet routes (for assignee/label/project, long
518+lists) means the user gets two different gestures depending on which
519+chip they tap — there's no muscle-memory carry-over.
520+
521+When you find yourself building a row like this, **use the formSheet
522+route for every picker in the row**, even the ones a standalone
523+centered card would handle fine. The cost is some empty space below
524+5–7 short rows; the gain is uniform tap → slide-up-sheet +
525+drag-down-to-dismiss behaviour across the whole row. Linear iOS /
526+Things 3 / Apple Reminders all do this for the same reason.
527+
528+The centered-card pattern stays correct for **isolated short menus**
529+(e.g. the chat-composer's "More" popover, the timeline's coalesce-
530+expand) where there's no neighbour to be consistent with.
531+
532+### 6. Destructive swipe: reveal only, no auto-fire — always pair with haptic
533+
534+iOS Mail / Linear iOS / Things: leftward swipe reveals a red Archive
535+button; the user **must tap it** to commit. The earlier mobile inbox
536+swipe auto-fired on full drag past the threshold and "felt wrong" — no
537+peek, easy to trigger by accident on a fast vertical scroll that
538+catches some horizontal motion. There is no native UX that auto-commits
539+a destructive action on swipe — match the platform standard.
540+
541+The rule:
542+
543+- `ReanimatedSwipeable` with `renderRightActions={<Pressable onPress={fireArchive} />}`.
544+- **No `onSwipeableOpen` auto-fire.** Drag → reveals the action; release
545+ past threshold → action stays revealed; tap action → commit; tap
546+ outside or drag back → cancel.
547+- One-shot `Haptics.impactAsync('medium')` when the drag crosses the
548+ action width. Wire via `useAnimatedReaction(() => drag.value <= -ACTION_WIDTH, ...)`
549+ + `runOnJS(Haptics.impactAsync)`. The shared-value reaction runs on
550+ the UI thread; `runOnJS` bridges to the JS-only Haptics call.
551+
552+See `apps/mobile/components/inbox/swipeable-inbox-row.tsx` for the
553+reference implementation. When adding a new swipe-to-action row
554+elsewhere, copy that pattern; do not reinvent.
555+
556+### 7. Tier C domain components: opportunistic upgrade only — no silent rewrites
557+
558+Tier C in `apps/mobile/docs/rnr-migration.md` §4 names the domain UI
559+files that stay where they are but need foundation upgrades
560+(`ActorAvatar`, `StatusIcon`, `PriorityIcon`, `PresenceDot`, etc.).
561+**You don't rewrite a Tier C file just because you're rendering it in
562+your new feature.** That spreads scope and stalls feature PRs.
563+
564+Two rules:
565+
566+1. **Touch only what your PR needs to touch.** If `ActorAvatar` has
567+ hardcoded `#71717a` and you're building an inbox feature that
568+ *uses* `<ActorAvatar>`, leave the hex alone. Note it for a future
569+ doc / cleanup PR.
570+2. **Upgrade Tier C only when you're modifying that file for a
571+ different real reason.** E.g. adding presence to chat header → you
572+ were going to touch `<ActorAvatar>` anyway → fold the RNR-Avatar
573+ migration + hex → token cleanup into the same PR.
574+
575+The pre-migration legacy persists because someone "while I'm in
576+here…"-style touched 21 files in one PR; we don't do that anymore.
577+Document any Tier C smells you spotted in the PR description as
578+follow-ups; surface for a future grouped Tier C cleanup PR.
242579
